소개

요즘 제대로 된 웹 애플리케이션은 모두 REST API를 포함하고 있다. Flickr에도 있고 Google, Bit.ly 및 NetFlix에도 있으며 자주 사용되는 다른 많은 애플리케이션도 마찬가지이다. 아키텍처 패턴으로서 REST는 기존 HTTP 동사를 일반적인 데이터 조작에 직관적으로 맵핑하고 데이터 입력과 준수 요구 사항이 적은 기존 SOAP 및 RPC 기반 아키텍처에 대한 경량 대체 아키텍처를 제공하기 때문에 자주 사용된다. 이것은 시간 및 비용 측면에서 긍정적인 의미를 가진다. REST 기반 API는 일반적으로 SOAP 및 RPC 기반 API에 비해 구현이 더 빠르고 개발이 더 쉽다.

자주 사용하는 약어

  • API: Application Program Interface
  • CRUD: Create Read Update Delete
  • DOM: Document Object Model
  • HTML: Hypertext Markup Language
  • HTTP: Hypertext Transfer Protocol
  • JSON: JavaScript Object Notation
  • MVC: Model-View-Controller
  • OOP: Object-Oriented Programming
  • ORM: Object-Relational Mapping
  • REST: Representational State Transfer
  • RPC: Remote Procedure Call
  • SQL: Structured Query Language
  • URL: Uniform Resource Locator
  • WSDL: Web Services Description Language
  • XML: Extensible Markup Language

이전 기사에서 필자는 Agavi MVC 프레임워크에 대해 소개하면서 이 프레임워크를 사용하면 확장 가능한 웹 애플리케이션을 신속하고 효율적으로 빌드할 수 있다고 설명했다(링크는 참고자료 참조). Agavi 사용 시 얻을 수 있는 여러 가지 장점은 다음과 같다.

  • 정교한 입력 필터링 및 유효성 검증
  • OOP 준수 아키텍처
  • 사용자 정의된 URL 라우팅
  • 확장 가능한 역할 기반 액세스 제어

Agavi는 REST API 개발자에게 매우 중요한 두 가지 기능(REST HTTP 메소드에 대한 내장 지원과 XML 및 JSON과 같은 여러 출력 유형에 대한 지원)도 제공한다.

이 기사에서는 Agavi를 사용한 단순 REST API 빌드의 프로세스에 대해 설명한다. Agavi 기반 애플리케이션이 이미 있는 경우 이 기사에서는 기존 프레임워크 규칙을 활용하고 애플리케이션 내부를 써드파티 개발자에게 노출하는 방법에 대해 설명한다. 새 REST 기반 애플리케이션을 작성하는 경우 이 기사에서는 Agavi를 사용하여 프로세스를 더 단순하고 효율적으로 만드는 방법에 대해 설명한다.


REST 이해하기

먼저 REST(Representational State Transfer)에 대해 간단하게 설명한다. REST는 메소드와 데이터 유형보다는 자원과 조치를 기반으로 한다는 점에서 SOAP와 차이가 있다. 자원은 단순히 조치가 수행될 오브젝트 또는 엔티티를 참조하는 URL(예: /users 또는 /photos)이며 조치는 네 가지 HTTP 동사(GET(검색), POST(작성), PUT(업데이트) 및 DELETE(제거)) 중 하나이다.

이해를 돕기 위해 단순한 예제를 사용한다. 사진 공유 애플리케이션이 있으며 개발자가 애플리케이션 데이터 저장소에서 원격으로 새로운 사진을 추가하거나 기존의 사진을 검색하기 위한 API 메소드가 필요하다고 가정한다. SOAP를 사용하는 경우에는 사진 매개변수를 입력으로 포함하고 있는 XML 인코딩 요청을 수신하고 사진 레코드 작성 또는 검색을 수행한 후 성공 또는 실패를 나타내는 XML 인코딩 응답을 리턴하는 createPhoto()getPhoto()와 같은 SOAP API 메소드를 일반적으로 가지고 있다. SOAP WSDL은 요청 및 응답 패킷 형식, 다양한 입력 매개변수의 데이터 유형 및 가능한 응답 값의 범위를 정의한다.

REST를 사용하는 경우에는 훨씬 더 단순하다. REST를 사용하는 경우에는 URL 엔드포인트(예: /photos)를 노출하고 필요한 조치에 대해 이해하기 위해 이 URL에 액세스하는 데 사용되는 HTTP 메소드를 검사한다. 따라서 예를 들어, HTTP 패킷을 /photos에 POST하여 새로운 사진을 작성하거나 요청을 GET /photos에 전송하여 사용 가능한 사진의 목록을 검색한다. 이 방식은 기존 HTTP 동사를 CRUD 조작에 맵핑하기 때문에 훨씬 더 이해하기 쉽고 필요한 요청/응답 헤더의 데이터 유형에 대한 공식적인 정의가 없기 때문에 자원 소비량도 적다.

URL 요청에 대한 일반적인 REST 규칙과 이러한 규칙의 의미는 다음과 같다.

  • GET /items: 항목의 목록 검색
  • GET /items/123: #123 항목 검색
  • POST /items: 새 항목 작성
  • PUT /items/123: #123 항목 업데이트
  • DELETE /items/123: #123 항목 제거

Agavi는 이러한 REST 규칙에 대한 내장 지원과 함께 제공된다. 이 시리즈의 이전 기사를 읽은 경우에는 이미 프레임워크가 GET 및 POST 요청을 조치의 executeRead()executeWrite() 메소드에 자동으로 맵핑한다는 것을 알고 있다. 비슷한 맥락에서 PUT 및 DELETE 요청도 조치의 executeCreate()executeRemove() 메소드에 자동으로 맵핑된다. 따라서 새 REST API를 정의하는 것은 이러한 조치 메소드를 정의하고 코드로 해당 메소드를 채운 후 요청의 경로를 해당 메소드로 올바르게 지정하기만 하면 될 만큼 단순해진다. 이 기사의 나머지 부분에서는 정확하게 이러한 작업을 수행하게 된다.


예제 애플리케이션 설정하기

REST API 구현을 시작하기 전에 몇가지 참고사항이 있다. 이 기사 전반에서 필자는 사용자가 작동 중인(Apache, PHP 및 MySQL) 개발 환경을 가지고 있으며 SQL 및 XML의 기본사항에 익숙하다고 가정한다. 또한 필자는 사용자가 Agavi를 사용한 애플리케이션 개발의 기본 원칙에 대해 잘 알고 있으며 조치, 보기, 모델 및 라우트 간 상호작용을 이해하고 Agavi 애플리케이션에서의 Doctrine 모델 사용에 익숙하다고 가정한다. 마지막으로 필자는 사용자의 Apache 웹 서버가 가상 호스팅, URL 재작성과 PUT 및 DELETE 요청을 지원하도록 구성되어 있다고 가정한다. 이러한 주제에 익숙하지 않은 경우에는 이 기사의 내용을 읽기 전에 Agavi 소개 기사 시리즈(링크는 참고자료 참조)를 읽어야 한다.

이 사례의 예제 애플리케이션은 서명과 저자의 단순한 데이터베이스이다. REST API는 써드파티 개발자가 일반적인 REST 규칙을 사용하여 이 데이터베이스에서 서적을 검색, 추가, 삭제 및 업데이트할 수 있도록 한다. 이 기사에서는 대부분 XML 요청 및 응답 본문에 대해 다루지만 끝부분에는 JSON 요청 및 응답을 처리하는 방법에 대해 설명하는 섹션도 있다.

1단계: 새 애플리케이션 초기화하기

시작하려면 먼저 이 기사의 개발 목표에 대한 시험대로 사용될 단순 Agavi 애플리케이션을 설정한다. Agavi 빌드 스크립트를 사용하여 새 프로젝트를 초기화하여 아래에 표시된 경우를 제외하고는 기본값을 승인한다.

shell> agavi project-wizard
Project name [New Agavi Project]: ExampleApp
Project prefix (used, for example, in the project base action) [Exampleapp]: ExampleApp
Should an Apache .htaccess file with rewrite rules be generated (y/n) [n]? y
...

이 단계에 있는 동안 Apache 구성에서 테스트 애플리케이션의 새 가상 호스트(예: http://example.localhost/)를 정의한 후 브라우저가 이 가상 호스트를 가리키게 한다. 그림 1과 같이 기본 Agavi 시작 페이지가 표시된다.


그림 1. 기본 Agavi 시작 페이지
기본 Agavi 시작 페이지의 화면 캡처

2단계: 새 모듈 및 해당 조치 추가하기

단순성을 위해 여기서는 계획하려고 하는 모든 조치가 Default 모듈이 아닌 모듈에 있다고 가정한다. 다음과 같이 명령 프롬프트로 돌아가서 Agavi 빌드 스크립트를 사용하여 새로운 Books 모듈을 작성한다.

shell> agavi module-wizard
Module name: Books
Space-separated list of actions to create for Books: Index Book
Space-separated list of views to create for Index [Success]: Success
Space-separated list of views to create for Book [Success]: Success Error
...

이러한 조치에는 잠시 후 REST 메소드가 추가된다.

Agavi 문서에서 권장하는 대로 여기서 Welcome 모듈도 제거해야 한다.

shell> rm -rf app/modules/Welcome
shell> rm -rf app/pub/welcome

3단계: 애플리케이션 라우팅 테이블 업데이트하기

다음으로 이전 섹션에서 설명한 표준 REST 라우트에 해당하는 라우트로 $ROOT/app/config/routing.xml에서 애플리케이션의 라우팅 테이블을 업데이트한다. Listing 1에 필요한 라우트 정의가 있다.


Listing 1. REST 라우트 정의
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
<ae:configuration>
<routes>

<!-- default action for "/" -->
<route name="index" pattern="^/$" module="Default" action="Index" />

<!-- REST-style routes -->
<route name="books" pattern="^/books" module="Books">
<route name=".index" pattern="^/$" action="Index" />
<route name=".book" pattern="^/(id:\d+)$" action="Book" />
</route>

</routes>
</ae:configuration>
</ae:configurations>

이제 Listing 1에 있는 라우트를 사용하여 새로 만든 조치에 액세스할 수 있다. 이를 확인하려면 http://example.localhost/books/로 이동하여 그림 2에 있는 것과 비슷한 스텁 보기가 제공되는지 확인한다.


그림 2. Agavi 스텁 HTML 보기
인덱스의 제목이 포함된 Agavi 스텁 HTML 보기의 화면 캡처

4단계: 서적 데이터베이스 및 모델 초기화하기

다음 단계는 애플리케이션 데이터베이스를 초기화하는 것이다. 따라서 아래와 같이 서적 레코드를 보유할 새 MySQL 테이블을 작성한다.

mysql> CREATE TABLE IF NOT EXISTS book (
-> id int(11) NOT NULL AUTO_INCREMENT,
-> title varchar(255) NOT NULL,
-> author varchar(255) NOT NULL,
-> PRIMARY KEY (`id`)
-> ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Query OK, 0 rows affected (0.13 sec)

이 테이블에 몇 가지 레코드를 제공하여 계속 진행한다.

mysql> INSERT INTO `book` (`id`, `title`, `author`) 
-> VALUES (1, 'Wolf Hall', 'Hilary Mantel');
Query OK, 1 row affected (0.08 sec)

mysql> INSERT INTO `book` (`id`, `title`, `author`)
-> VALUES (2, 'Prayers for Rain', 'Dennis Lehane');
Query OK, 1 row affected (0.08 sec)

그런 다음 Doctrine 오브젝트 관계형 맵퍼(링크는 참고자료 참조)를 다운로드하고 Doctrine 라이브러리를 $ROOT/libs/doctrine에 추가한다. 또한 $ROOT/app/config/settings.xml에서 애플리케이션 설정을 업데이트하여 데이터베이스 지원을 활성화한 후 Agavi의 Doctrine 어댑터를 사용하기 위해 데이터베이스 구성 파일(일반적으로 $ROOT/app/config/databases.xml에 있음)을 업데이트해야 한다. Listing 2에 이러한 구성의 모양에 대한 예제가 있다.


Listing 2. Doctrine ORM 구성
        
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
xmlns="http://agavi.org/agavi/config/parts/databases/1.0">
<ae:configuration>
<databases default="doctrine">
<database name="doctrine" class="AgaviDoctrineDatabase">
<ae:parameter name="dsn">mysql://user:pass@localhost/example
</ae:parameter>
<ae:parameter name="load_models">%core.lib_dir%/model
</ae:parameter>
</database>
</databases>
</ae:configuration>
</ae:configurations>

여기서 Doctrine을 사용하여 이러한 테이블에 대한 모델을 생성할 수 있다. 결과 모델 클래스를 수동으로 $ROOT/app/lib/model/ 디렉토리에 복사하는 것을 잊지 않는다.

shell> cp /tmp/models/Book.php app/lib/model/
shell> cp /tmp/models/generated/BaseBook.php app/lib/model/

Doctrine을 Agavi와 통합하고 이를 사용하여 데이터베이스 테이블에서 모델을 생성하는 프로세스는 Agavi 소개 기사 시리즈의 Part 3(링크는 이 기사의 참고자료 참조)에서 자세하게 설명한다.

5단계: XML 출력 유형 정의하기

기본적으로 Agavi는 HTML 출력용으로만 구성되어 있다. 이 예제 REST API는 처음부터 XML을 지원하기 때문에 이 출력 유형을 정의하고 관련 응답 헤더를 지정한 후 이 응답 헤더를 기본값으로 표시해야 한다. 이를 위해서는 Listing 3에 있는 코드를 사용하여 $ROOT/app/config/output_types.xml에 있는 출력 유형 구성 파일을 업데이트한다.


Listing 3. XML 출력 유형 구성
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
xmlns="http://agavi.org/agavi/config/parts/output_types/1.0">
<ae:configuration>
<output_types default="xml">

<output_type name="html">
...
</output_type>

<output_type name="xml">
<ae:parameter name="http_headers">
<ae:parameter name="Content-Type">text/xml; charset=UTF-8
</ae:parameter>
</ae:parameter>
</output_type>
</output_types>
</ae:configuration>
</ae:configurations>

위와 같이 작동하지 않는 경우 위의 단계는 Agavi 소개 기사 시리즈의 Part 1(링크는 이 기사의 참고자료 참조)에 자세히 설명되어 있다는 것을 기억한다. 대신 다운로드에서 예제 애플리케이션의 완전한 코드 아카이브를 다운로드할 수 있다.


GET 요청 처리하기

일반적인 REST API는 두 가지 유형의 GET 요청(첫 번째 유형은 자원 목록(GET /books/)에 대한 것이고 두 번째 유형은 특정 자원(GET /books/123)에 대한 것임)을 지원해야 한다. 이전 섹션에서 다룬 라우팅 테이블을 사용하면 Agavi는 자동으로 이들의 경로를 각각 Books_IndexAction::executeRead() 메소드와 Books_BookAction::executeRead() 메소드로 지정한다.

IndexAction의 executeRead() 메소드는 200(OK) 상태 코드와 사용 가능한 모든 서적 레코드의 목록이 포함된 GET /books/ 요청에 응답해야 한다. Listing 4에서는 코드의 모양을 보여 준다.


Listing 4. GET 요청에 대한 IndexAction 핸들러
<?php
class Books_IndexAction extends ExampleAppBooksBaseAction
{
public function executeRead(AgaviRequestDataHolder $rd)
{
$q = Doctrine_Query::create()
->from('Book b');
$result = $q->execute(array(), Doctrine::HYDRATE_ARRAY);
$this->setAttribute('result', $result);
return 'Success';
}
}
?>

Listing 4에서는 Doctrine을 사용하여 서적 데이터베이스 테이블에서 레코드에 대한 쿼리를 수행하여 결과를 IndexSuccessView의 보기 변수로 설정한다. 다음 단계는 executeXml() 메소드를 IndexSuccessView에 추가하여 이 정보를 XML 문서로 출력하는 것이다. Listing 5에 코드가 있다.


Listing 5. IndexSuccess XML 보기
<?php
class Books_IndexSuccessView extends ExampleAppBooksBaseView
{
public function executeXml(AgaviRequestDataHolder $rd)
{
$result = $this->getAttribute('result');
$dom = new DOMDocument('1.0', 'utf-8');
$root = $dom->createElement('result');
$dom->appendChild($root);
$xml = simplexml_import_dom($dom);
foreach ($result as $r) {
$item = $xml->addChild('item');
$item->addChild('id', $r['id']);
$item->addChild('author', $r['author']);
$item->addChild('title', $r['title']);
}
return $xml->asXml();
}
}
?>

여기에는 그다지 복잡한 내용이 포함되어 있지 않다. executeXml() 메소드는 새 DOM 문서를 생성하고 루트 요소를 작성한 후 SimpleXML로 나머지 XML 트리를 빌드하여 이러한 XML 트리를 Doctrine 결과 세트의 정보로 채운다.

이를 확인하려면 웹 브라우저에서 URL http://example.localhost/books를 요청한다. 그림 3과 같은 결과가 발생해야 한다(그림 3의 텍스트 전용 버전 보기).


그림 3. 모든 서적을 위한 GET 요청에 대한 XML 응답
모든 서적을 위한 GET 요청에 대한 XML 응답의 화면 캡처

비슷한 맥락에서 BookAction의 executeRead() 메소드는 요청된 서적의 세부 사항이 들어 있는 XML 문서가 포함된 GET /books/{id} 요청에 응답해야 한다. Listing 6에서는 코드의 모양을 보여 준다.


Listing 6. GET 요청에 대한 BookAction 핸들러
<?php
class Books_BookAction extends ExampleAppBooksBaseAction
{
public function executeRead(AgaviRequestDataHolder $rd)
{
$q = Doctrine_Query::create()
->from('Book b')
->addWhere('id = ?', $rd->getParameter('id'));
$result = $q->execute(array(), Doctrine::HYDRATE_ARRAY);
if (count($result) == 0) {
return 'Error';
}
$this->setAttribute('result', $result);
return 'Success';
}
}
?>

Listing 7에는 해당 유효성 검증기 정의가 있으며 Listing 8에는 해당 BookSuccess 보기가 있다.


Listing 7. BookAction 유효성 검증기
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
parent="%core.module_dir%/Books/config/validators.xml"
>
<ae:configuration>
<validators>
<validator class="number">
<arguments>
<argument>id</argument>
</arguments>
<ae:parameters>
<ae:parameter name="required">true</ae:parameter>
<ae:parameter name="min">1</ae:parameter>
</ae:parameters>
</validator>
</validators>
</ae:configuration>
</ae:configurations>


Listing 8. BookSuccess XML 보기
<?php
class Books_BookSuccessView extends ExampleAppBooksBaseView
{
public function executeXml(AgaviRequestDataHolder $rd)
{
$result = $this->getAttribute('result');
$dom = new DOMDocument('1.0', 'utf-8');
$root = $dom->createElement('result');
$dom->appendChild($root);
$xml = simplexml_import_dom($dom);
foreach ($result as $r) {
$item = $xml->addChild('item');
$item->addChild('id', $r['id']);
$item->addChild('author', $r['author']);
$item->addChild('title', $r['title']);
}
return $xml->asXml();
}
}
?>

이를 확인하려면 웹 브라우저에서 URL http://example.localhost/books/1을 요청한다. 그림 4와 같은 결과가 발생해야 한다(그림 4의 텍스트 전용 버전 보기).


그림 4. 개별 서적을 위한 GET 요청에 대한 XML 응답
개별 서적을 위한 GET 요청에 대한 XML 응답의 화면 캡처

지정된 자원을 사용할 수 없는 경우에는 404(찾을 수 없음) 상태 코드를 리턴하는 것이 좋다. 이는 BookErrorView의 경로를 애플리케이션의 기본값인 Error404SuccessView로 재지정한 후 해당 보기를 404 메시지 본문을 리턴하는 executeXml() 메소드로 업데이트하여 쉽게 수행된다. Listing 9에 코드가 있다.


Listing 9. Error404Success XML 보기
<?php
class Default_Error404SuccessView extends ExampleAppDefaultBaseView
{
public function executeXml(AgaviRequestDataHolder $rd)
{
$this->getResponse()->setHttpStatusCode('404');
$dom = new DOMDocument('1.0', 'utf-8');
$root = $dom->createElement('error');
$dom->appendChild($root);
$message = $dom->createElement('message', '404 Not Found');
$root->appendChild($message);
return $dom->saveXml();
}
}
?>


POST 요청 처리하기

POST 요청 처리는 조금 더 복잡하다. 일반적인 REST 규칙은 POST 요청이 자원에 필요한 모든 입력(이 경우에는 저자 및 제목)이 들어 있는 요청 본문이 포함된 새 자원을 작성하는 것이다. 이제 Agavi는 자동으로 URL 인코딩 요청 본문을 읽어 개별 요청 매개변수로 변환할 수 있다. 하지만 POST 및 PUT 요청의 경우에 발생하는 것과 같이 요청 본문에 XML 문서가 포함되어 있는 경우에는 XML 데이터를 조치 메소드에 사용하기 적합한 요청 매개변수로 변환하기 위해 추가적인 처리가 필요하다.

Listing 10에는 새 서적 항목을 나타내는 이러한 XML 문서 중 하나의 예제가 포함되어 있다.


Listing 10. 새 서적 항목을 나타내는 XML 문서
<book>
<title>The Da Vinci Code</title>
<author>Dan Brown</author>
</book>

이를 수행하는 가장 쉬운 방법은 AgaviWebRequest 클래스를 서브클래스화하여 수신 요청의 Content-Type 헤더를 확인한 후 헤더가 XML 요청 본문을 나타내는 경우 XML에서 필요한 처리를 수행하는 것이다. Listing 11에 이러한 서브클래스의 모양에 대한 예제가 있다.


Listing 11. 사용자 정의 HTTP 요청 핸들러 클래스
<?php
// credit: David Zuelke
class ExampleAppWebRequest extends AgaviWebRequest {
public function initialize(AgaviContext $context, array $parameters = array()) {
parent::initialize($context, $parameters);
$rd = $this->getRequestData();
if (stristr($rd->getHeader('Content-Type'), 'text/xml')) {
switch ($_SERVER['REQUEST_METHOD']) {
case 'PUT': {
$xml = $rd->removeFile('put_file')->getContents();
break;
}
case 'POST': {
$xml = $rd->removeFile('post_file')->getContents();
break;
}
default: {
$xml = '';
}
}
$rd->setParameters((array)simplexml_load_string($xml));
}
}
}
?>

PUT 및 POST 데이터는 URL 인코딩되지 않은 경우 요청의 put_filepost_file 파일 변수에 저장된다. Listing 11에서는 이 데이터를 요청에서 끌어내어 SimpleXML을 사용하여 오브젝트로 변환한 후 AgaviRequestDataHolder의 setParameters() 메소드와 함께 사용하기에 적합한 배열에 이 오브젝트를 캐스트한다. 이제 이 데이터는 AgaviRequestDataHolder의 getParameter() 메소드를 사용하여 조치 메소드에서 보통 때와 같은 방법으로 액세스할 수 있다.

업데이트된 클래스 정의를 $ROOT/app/lib/request/ExampleAppWebRequest.class.php에 저장한 후 $ROOT/app/config/autoload.xml에 추가하여 로드할 수 있다. Listing 12에서는 이 파일에 추가하는 것을 보여 준다.


Listing 12. Agavi 자동 로드 프로그램 구성
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns="http://agavi.org/agavi/config/parts/autoload/1.0"
xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
parent="%core.system_config_dir%/autoload.xml">
<ae:configuration>
<autoload name="ExampleAppBaseAction">
%core.lib_dir%/action/ExampleAppBaseAction.class.php</autoload>
<autoload name="ExampleAppBaseModel">
%core.lib_dir%/model/ExampleAppBaseModel.class.php</autoload>
<autoload name="ExampleAppBaseView">
%core.lib_dir%/view/ExampleAppBaseView.class.php</autoload>
<autoload name="ExampleAppWebRequest">
%core.lib_dir%/request/ExampleAppWebRequest.class.php</autoload>
<autoload name="Doctrine">
%core.app_dir%/../libs/doctrine/Doctrine.php</autoload>
</ae:configuration>
</ae:configurations>

이것이 전부는 아니다. 일반적인 REST 규칙은 POST 요청이 새 자원을 작성하고 PUT 요청이 기존 자원을 업데이트하는 것이다. 하지만 Agavi의 기본 설정은 POST 요청을 executeWrite() 메소드에 맵핑하고 PUT 요청을 executeCreate() 메소드에 맵핑한다. 이러한 내용은 REST 사용자에게 혼동을 유발할 수 있기 때문에 일반적으로 새 맵핑을 반영하도록 $ROOT/app/config/factories.xml을 업데이트하여 이러한 맵핑을 전환하는 것이 좋다. Listing 13에 코드가 있다.


Listing 13. Agavi 조치 메소드에 HTTP 요청 메소드 팩토리 재맵핑
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
xmlns="http://agavi.org/agavi/config/parts/factories/1.0">
<ae:configuration>
...

<request class="ExampleAppWebRequest">
<ae:parameter name="method_names">
<ae:parameter name="POST">create</ae:parameter>
<ae:parameter name="GET">read</ae:parameter>
<ae:parameter name="PUT">write</ae:parameter>
<ae:parameter name="DELETE">remove</ae:parameter>
</ae:parameter>
</request>
...
</ae:configuration>

모든 사항이 적절하게 수행된 경우에는 이제 Books_IndexAction::executeCreate() 메소드를 정의하여 POST 요청을 처리할 수 있게 된다. Listing 14에 코드가 있다.


Listing 14. POST 요청에 대한 IndexAction 핸들러
<?php
class Books_IndexAction extends ExampleAppBooksBaseAction
{
public function executeCreate(AgaviRequestDataHolder $rd)
{
$book = new Book;
$book->author = $rd->getParameter('author');
$book->title = $rd->getParameter('title');
$book->save();
$this->setAttribute('result', array($book->toArray()));
return 'Success';
}
}
?>

조치 유효성 검증기를 업데이트하여 이러한 요청 변수를 허용하는 것을 잊지 않는다(Listing 15).


Listing 15. IndexAction 유효성 검증기
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
parent="%core.module_dir%/Books/config/validators.xml"
>
<ae:configuration>

<validators method="create">
<validator class="string">
<arguments>
<argument>author</argument>
</arguments>
<ae:parameters>
<ae:parameter name="required">true</ae:parameter>
</ae:parameters>
</validator>
<validator class="string">
<arguments>
<argument>title</argument>
</arguments>
<ae:parameters>
<ae:parameter name="required">true</ae:parameter>
</ae:parameters>
</validator>
</validators>

</ae:configuration>
</ae:configurations>

REST 규칙은 성공적인 POST에 대한 응답이 201(작성됨) 상태 코드, 새 자원의 URL을 나타내는 위치 헤더 및 응답 본문에 있는 자원의 표시를 포함하도록 지시한다. 이러한 사항은 모두 Listing 16과 같이 IndexSuccessView를 약간 수정하여 달성된다.


Listing 16. 개정된 IndexSuccess XML 보기
<?php
class Books_IndexSuccessView extends ExampleAppBooksBaseView
{
public function executeXml(AgaviRequestDataHolder $rd)
{
$result = $this->getAttribute('result');
if ($this->getContext()->getRequest()->getMethod() == 'create') {
$this->getResponse()->setHttpStatusCode('201');
$this->getResponse()->setHttpHeader(
'Location',
$this->getContext()->getRouting()->gen(
'book',
array('id' => $result[0]['id']
)));
}
$dom = new DOMDocument('1.0', 'utf-8');
$root = $dom->createElement('result');
$dom->appendChild($root);
$xml = simplexml_import_dom($dom);
foreach ($result as $r) {
$item = $xml->addChild('item');
$item->addChild('id', $r['id']);
$item->addChild('author', $r['author']);
$item->addChild('title', $r['title']);
}
return $xml->asXml();
}
}
?>


PUT 및 DELETE 요청 처리하기

앞서 언급했듯이 PUT 요청은 기본 자원에 대한 수정을 나타내는 데 사용되기 때문에 요청 문자열에 자원 ID를 포함한다. 성공적인 PUT은 기존 자원이 PUT 요청 본문에 지정된 자원으로 바뀌었음을 의미한다. 성공적인 PUT에 대한 응답은 응답 본문에 업데이트된 자원의 표시가 포함된 상태 코드 200(OK) 또는 응답 본문이 비어 있는 상태 코드 204(컨텐츠 없음)가 될 수 있다.

Listing 17에는 업데이트된 유효성 검증기 정의가 있다.


Listing 17. BookAction 유효성 검증기
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations
xmlns="http://agavi.org/agavi/config/parts/validators/1.0"
xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
parent="%core.module_dir%/Books/config/validators.xml"
>
<ae:configuration>

<validators>
<validator class="number">
<arguments>
<argument>id</argument>
</arguments>
<ae:parameters>
<ae:parameter name="required">true</ae:parameter>
<ae:parameter name="min">1</ae:parameter>
</ae:parameters>
</validator>
</validators>

<validators method="write">
<validator class="string">
<arguments>
<argument>author</argument>
</arguments>
<ae:parameters>
<ae:parameter name="required">true</ae:parameter>
</ae:parameters>
</validator>
<validator class="string">
<arguments>
<argument>title</argument>
</arguments>
<ae:parameters>
<ae:parameter name="required">true</ae:parameter>
</ae:parameters>
</validator>
</validators>

</ae:configuration>
</ae:configurations>

Listing 18에는 PUT 요청을 처리할 Books_BookAction::executeWrite() 조치 메소드의 코드가 있다.


Listing 18. PUT 요청에 대한 BookAction 핸들러
<?php
class Books_BookAction extends ExampleAppBooksBaseAction
{
public function executeWrite(AgaviRequestDataHolder $rd)
{
$book = Doctrine::getTable('book')->find($rd->getParameter('id'));
if (!is_object($book)) {
return 'Error';
}
$book->author = $rd->getParameter('author');
$book->title = $rd->getParameter('title');
$book->save();
$this->setAttribute('result', array($book->toArray()));
return 'Success';
}
}
?>

비슷한 맥락에서 executeRemove() 메소드는 DELETE 요청을 처리하여 데이터 저장소에서 지정된 자원을 제거한다. Listing 19에 이 메소드의 코드가 표시된다.


Listing 19. DELETE 요청에 대한 BookAction 핸들러
<?php
class Books_BookAction extends ExampleAppBooksBaseAction
{
public function executeRemove(AgaviRequestDataHolder $rd)
{
$q = Doctrine_Query::create()
->delete('Book')
->addWhere('id = ?', $rd->getParameter('id'));
$result = $q->execute();
$this->setAttribute('result', null);
return 'Success';
}
}
?>

성공적인 DELETE 요청에 대한 응답은 응답 본문에 상태가 있는 200(OK) 또는 응답 본문이 비어 있는 204(컨텐츠 없음)가 될 수 있다. 후자는 Listing 20에서와 같이 BookSuccessView를 변경하여 쉽게 달성할 수 있다.


Listing 20. BookSuccess XML 보기
<?php
class Books_BookSuccessView extends ExampleAppBooksBaseView
{
public function executeXml(AgaviRequestDataHolder $rd)
{
if ($this->getContext()->getRequest()->getMethod() == 'remove') {
$this->getResponse()->setHttpStatusCode('204');
return false;
}
$result = $this->getAttribute('result');
$dom = new DOMDocument('1.0', 'utf-8');
$root = $dom->createElement('result');
$dom->appendChild($root);
$xml = simplexml_import_dom($dom);
foreach ($result as $r) {
$item = $xml->addChild('item');
$item->addChild('id', $r['id']);
$item->addChild('author', $r['author']);
$item->addChild('title', $r['title']);
}
return $xml->asXml();
}
}
?>


JSON 지원 추가하기

이전 섹션에서는 단순한 XML 기반 REST API의 설정 방법에 대해 설명했다. 하지만 JSON이 데이터 교환 형식으로 사용되는 경우가 많아지고 있기 때문에 REST API에서 JSON 요청 및 응답 본문도 지원해야 하는 경우가 많다. Agavi의 유연한 출력 유형을 사용하면 이를 쉽게 처리할 수 있다.

1단계: JSON 출력 유형 활성화하기

시작하려면 Listing 21에서와 같이 JSON 요청에 대한 핸들러를 라우팅 테이블에 추가한다.


Listing 21. JSON 라우트 핸들러
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
<ae:configuration>
<routes>
<!-- handler for JSON requests -->
<route name="json" pattern=".json$" cut="true" stop="false"
output_type="json" />
...
</routes>
</ae:configuration>
</ae:configurations>

라우팅 테이블 위쪽에 있는 이 라우트는 .json 접미부로 끝나는 모든 요청을 일치시키며 JSON 출력 유형을 사용하도록 이러한 요청을 설정한다. 하지만 이것이 전부는 아니다.

  • cut 속성은 처리하기 전에 요청 URL에서 일치된 하위 문자열 세그먼트를 삭제할지 여부를 나타낸다. 이 사례에서는 일치가 발생하면 요청 URL에서 .json 접미부가 제거되도록 true로 설정된다.
  • 라우트 정의의 stop 속성은 첫 번째 일치 이후 라우트 처리를 계속해야 하는지 여부를 나타낸다. 이 사례에서는 나머지 요청 URL이 일치되고 적절한 조치가 호출될 때까지 요청이 목록에서 계속 아래로 진행되도록 하기 위해 false로 설정된다.

이 구성의 순수한 효과는 Agavi가 예를 들어, http://example.localhost/books/1.json에 대한 요청을 수신하면 Agavi는 라우팅 테이블을 확인하고 최상위 레벨 catch-all 라우트와의 즉각적인 일치를 찾는다. 그런 다음 Agavi는 요청 URL에서 .json 접미부를 제거하고 요청의 출력 유형을 JSON으로 설정한다. 그리고 books.book 라우트와의 일치를 찾아서 BookAction을 호출할 때까지 나열된 라우트에 대해 요청 http://example.localhost/books/1의 나머지 부분을 계속 확인한다. BookAction이 완료되면 Agavi는 보기에서 executeJson() 메소드를 찾아서 이 메소드를 실행한 후 출력을 클라이언트에 리턴한다.

다음으로 새 JSON 출력 유형을 정의한다(Listing 22).


Listing 22. JSON 출력 유형 정의
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0"
xmlns="http://agavi.org/agavi/config/parts/output_types/1.0">
<ae:configuration>
<output_types default="xml">
<output_type name="json">
<ae:parameter name="http_headers">
<ae:parameter name="Content-Type">application/json</ae:parameter>
</ae:parameter>
</output_type>
...
</output_types>
</ae:configuration>
</ae:configurations>

2단계: JSON 웹 요청에서 매개변수 추출하기

XML과 마찬가지로 Agavi는 JSON 패킷을 자동으로 요청 매개변수로 변환하지 않는다. 따라서 Content-Type 헤더에 중점을 두고 PHP의 json_decode() 함수를 사용하여 AgaviRequestDataHolder의 setParameters() 메소드에 전달될 수 있는 PHP 배열에 JSON 값을 추출하여 사용자 정의 ExampleAppWebRequest 클래스를 업데이트하여 이 태스크를 처리한다. Listing 23에 코드가 있다.


Listing 23. 개정된 웹 요청 핸들러
<?php
class ExampleAppWebRequest extends AgaviWebRequest {
public function initialize(AgaviContext $context, array $parameters = array()) {
parent::initialize($context, $parameters);
$rd = $this->getRequestData();
// handle XML requests
if (stristr($rd->getHeader('Content-Type'), 'text/xml')) {
switch ($_SERVER['REQUEST_METHOD']) {
case 'PUT': {
$xml = $rd->removeFile('put_file')->getContents();
break;
}
case 'POST': {
$xml = $rd->removeFile('post_file')->getContents();
break;
}
default: {
$xml = '';
}
}
$rd->setParameters((array)simplexml_load_string($xml));
}
// handle JSON requests
if (stristr($rd->getHeader('Content-Type'), 'application/json')) {
switch ($_SERVER['REQUEST_METHOD']) {
case 'PUT': {
$json = $rd->removeFile('put_file')->getContents();
break;
}
case 'POST': {
$json = $rd->removeFile('post_file')->getContents();
break;
}
default: {
$json = '{}';
}
}
$rd->setParameters(json_decode($json, true));
}
}
}
?>

3단계: 애플리케이션 보기 업데이트하기

마지막 단계는 JSON 출력을 지원하도록 다양한 애플리케이션 보기를 업데이트하는 것이다. 이를 위해서는 executeJson() 메소드를 IndexSuccessView(Listing 24) 및 BookSuccessView(Listing 25)에 첨부한다.


Listing 24. IndexSuccess JSON 보기
<?php
class Books_IndexSuccessView extends ExampleAppBooksBaseView
{
public function executeJson(AgaviRequestDataHolder $rd)
{
$result = $this->getAttribute('result');
if ($this->getContext()->getRequest()->getMethod() == 'create') {
$this->getResponse()->setHttpStatusCode('201');
$this->getResponse()->setHttpHeader('Location',
$this->getContext()->getRouting()->gen(
'book',
array('id' => $result[0]['id']
)));
}
return json_encode($result);
}
}
?>


Listing 25. BookSuccess JSON 보기
<?php
class Books_BookSuccessView extends ExampleAppBooksBaseView
{
public function executeJson(AgaviRequestDataHolder $rd)
{
if ($this->getContext()->getRequest()->getMethod() == 'remove') {
$this->getResponse()->setHttpStatusCode('204');
return false;
}
return json_encode($this->getAttribute('result'));
}
}
?>

이를 확인하려면 웹 브라우저가 http://example.localhost/books/.json 또는 http://example.localhost/books/1.json을 가리키도록 하고 해당 데이터가 포함된 JSON 인코딩 응답 패킷을 수신해야 한다. 그림 5에는 Firebug 디버거에 표시된 것과 같은 이 패킷의 예제가 있다. (그림 5의 텍스트 전용 버전 보기)


그림 5. 모든 서적을 위한 GET 요청에 대한 JSON 응답
모든 서적을 위한 GET 요청에 대한 JSON 응답의 화면 캡처

위에서 설명한 JSON 지원은 조치 코드는 건드리지 않고 단순하게 다양한 보기에서 변경사항을 작성하여 활성화되었다는 것에 유의한다. 개발자가 조치가 아니라 보기에서 다양한 출력 유형이 처리되는 방법을 결정할 수 있도록 하여 Agavi는 여전히 MVC 원칙과 DRY(Don't Repeat Yourself) 원칙에 충실하면서 코드 중복을 최소화한다.


결론

Agavi는 REST API 빌드에 필요한 완전하게 실현된 프레임워크를 제공하여 애플리케이션 개발자가 직관적인 경량 아키텍처 패턴을 사용하여 애플리케이션 함수에 써드파티가 쉽게 액세스할 수 있도록 한다. Agavi의 REST 라우트에 대한 내장 지원과 새로운 출력 유형을 신속하게 지원하는 기능으로 인해 Agavi는 신속한 API 개발 및 전개에 가장 적합하다. Agavi MVC 구현은 또한 구현 단계 중에는 언제든지(애플리케이션이 전개된 후에도) 기존 비즈니스 로직에 대한 영향을 최소화하면서 애플리케이션에 REST API를 추가할 수 있음을 의미한다.

다운로드에서 이 기사에서 구현된 모든 코드와 예제 API에서 GET, POST, PUT 및 DELETE 요청을 수행하기 위해 사용할 수 있는 단순 jQuery 기반 테스트 스크립트를 확인한다. 코드를 확보하여 이 코드에 새로운 항목을 추가해 보기를 권장한다. 이렇게 추가해도 문제는 절대 발생하지 않으며 학습에 확실히 도움이 된다. 한번 해 보자.


+ Recent posts