diff --git a/Cargo.toml b/Cargo.toml index 892a98b..304b32c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rusaint" -version = "0.1.2" +version = "0.1.3" description = "Easy-to-use SSU u-saint client" keywords = ["ssu", "u-saint", "scraping", "parser"] categories = ["web-programming"] diff --git a/src/application/course_grades/mod.rs b/src/application/course_grades/mod.rs index 05a0ffd..e5af849 100644 --- a/src/application/course_grades/mod.rs +++ b/src/application/course_grades/mod.rs @@ -1,11 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use crate::{ define_elements, model::SemesterType, - session::USaintSession, webdynpro::{ - application::{client::body::Body, Application}, + application::{body::Body, Application}, element::{ action::Button, complex::sap_table::{ @@ -29,13 +28,11 @@ use super::USaintApplication; define_usaint_application!( #[doc = "[학생 성적 조회](https://ecc.ssu.ac.kr/sap/bc/webdynpro/SAP/ZCMB3W0017)"] - pub struct CourseGrades + pub struct CourseGrades<"ZCMB3W0017"> ); #[allow(unused)] impl<'a> CourseGrades { - const APP_NAME: &str = "ZCMB3W0017"; - // Elements for Grade Summaries define_elements!( // Grade summaries by semester @@ -75,23 +72,6 @@ impl<'a> CourseGrades { GRADE_BY_CLASSES_TABLE: SapTable<'a> = "ZCMB3W0017.ID_0001:VIW_MAIN.TABLE_1"; ); - /// 새로운 학기별 성적 조회 애플리케이션을 만듭니다. - /// ### 예시 - /// ```no_run - /// # tokio_test::block_on(async { - /// #use std::sync::Arc; - /// # use rusaint::USaintSession; - /// - /// let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); - /// # }) - /// ``` - pub async fn new(session: Arc) -> Result { - Ok(CourseGrades( - USaintApplication::with_session(Self::APP_NAME, session).await?, - )) - } - async fn close_popups(&mut self) -> Result<(), WebDynproError> { fn make_close_event(app: &CourseGrades) -> Option { let body = app.body(); @@ -212,14 +192,18 @@ impl<'a> CourseGrades { /// # use std::sync::Arc; /// # use rusaint::USaintSession; /// # use self::model::CourseType; + /// # use rusaint::application::USaintApplicationBuilder; /// # let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); + /// let app = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); /// let summary = app.recorded_summary(CourseType::Bachelor).unwrap(); /// println!("{:?}", summary); /// // GradeSummary { ... } /// # }) /// ``` - pub async fn recorded_summary(&mut self, course_type: CourseType) -> Result { + pub async fn recorded_summary( + &mut self, + course_type: CourseType, + ) -> Result { self.close_popups().await?; self.select_course(course_type).await?; let body = self.body(); @@ -245,15 +229,19 @@ impl<'a> CourseGrades { /// # tokio_test::block_on(async { /// # use std::sync::Arc; /// # use rusaint::USaintSession; + /// # use rusaint::application::USaintApplicationBuilder; /// # use self::model::CourseType; /// # let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); + /// let app = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); /// let summary = app.certificated_summary(CourseType::Bachelor).unwrap(); /// println!("{:?}", summary); /// // GradeSummary { ... } /// # }) /// ``` - pub async fn certificated_summary(&mut self, course_type: CourseType) -> Result { + pub async fn certificated_summary( + &mut self, + course_type: CourseType, + ) -> Result { self.close_popups().await?; self.select_course(course_type).await?; let body = self.body(); @@ -280,14 +268,18 @@ impl<'a> CourseGrades { /// # use std::sync::Arc; /// # use rusaint::USaintSession; /// # use self::model::CourseType; + /// # use rusaint::application::USaintApplicationBuilder; /// # let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); + /// let app = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); /// let semesters = app.semesters(CourseType::Bachelor).unwrap(); /// println!("{:?}", semesters); /// // [SemesterGrade { ... }, SemesterGrade { ... }] /// # }) /// ``` - pub async fn semesters(&mut self, course_type: CourseType) -> Result, WebDynproError> { + pub async fn semesters( + &mut self, + course_type: CourseType, + ) -> Result, WebDynproError> { fn parse_rank(value: String) -> Option<(u32, u32)> { let mut spl = value.split("/"); let first: u32 = spl.next()?.parse().ok()?; @@ -396,9 +388,10 @@ impl<'a> CourseGrades { /// # tokio_test::block_on(async { /// # use std::sync::Arc; /// # use rusaint::USaintSession; + /// # use rusaint::application::USaintApplicationBuilder; /// # use rusaint::model::{CourseType, SemesterType}; /// # let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); + /// let app = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); /// let classes = app.classes(CourseType::Bachelor, "2022", SemesterType::Two, false).unwrap(); /// println!("{:?}", classes); // around 3s(depends on network environment) /// // [ClassGrade { ... }, ClassGrade { ... }] @@ -410,8 +403,9 @@ impl<'a> CourseGrades { /// # use std::sync::Arc; /// # use rusaint::USaintSession; /// # use rusaint::model::{CourseType, SemesterType}; + /// # use rusaint::application::USaintApplicationBuilder; /// # let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); + /// let app = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); /// let classes = app.classes("2022", SemesterType::Two, true).unwrap(); /// println!("{:?}", classes); // around 10s(depends on network environment) /// // [ClassGrade { ... }, ClassGrade { ... }] @@ -488,9 +482,10 @@ impl<'a> CourseGrades { /// # tokio_test::block_on(async { /// # use std::sync::Arc; /// # use rusaint::USaintSession; + /// # use rusaint::application::USaintApplicationBuilder; /// # use self::model::CourseType; /// # let session = Arc::new(USaintSession::with_password("20212345", "password").await.unwrap()); - /// let app = CourseGrades::new(session).await.unwrap(); + /// let app = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); /// let classes = app.classes(CourseType::Bachelor, "2022", SemesterType::Two, false).unwrap(); /// let class = classes.iter().first().unwrap(); /// let class_detail = app.class_detail("2022", SemesterType::Two, class.code()); @@ -560,12 +555,9 @@ mod test { use std::sync::{Arc, OnceLock}; use crate::{ - application::course_grades::CourseGrades, + application::{USaintApplicationBuilder, course_grades::CourseGrades}, session::USaintSession, - webdynpro::{ - application::Application, - element::{layout::PopupWindow, Element}, - }, + webdynpro::{element::{layout::PopupWindow, Element}, application::Application}, }; use dotenv::dotenv; @@ -590,7 +582,11 @@ mod test { #[tokio::test] async fn close_popups() { let session = get_session().await.unwrap(); - let mut app = CourseGrades::new(session).await.unwrap(); + let mut app = USaintApplicationBuilder::new() + .session(session) + .build_into::() + .await + .unwrap(); app.close_popups().await.unwrap(); let body = app.body(); let popup_selector = diff --git a/src/application/course_schedule.rs b/src/application/course_schedule.rs index 8187c88..ecaf046 100644 --- a/src/application/course_schedule.rs +++ b/src/application/course_schedule.rs @@ -2,19 +2,18 @@ use crate::{ define_elements, model::SemesterType, webdynpro::{ + application::Application, element::{action::Button, complex::SapTable, layout::TabStrip, selection::ComboBox}, - error::WebDynproError, application::Application, + error::WebDynproError, }, }; use super::USaintApplication; -define_usaint_application!(pub struct CourseSchedule); +define_usaint_application!(pub struct CourseSchedule<"ZCMW2100">); #[allow(unused)] impl<'a> CourseSchedule { - const APP_NAME: &str = "ZCMW2100"; - define_elements! { PERIOD_YEAR: ComboBox<'a> = "ZCMW_PERIOD_RE.ID_A61C4ED604A2BFC2A8F6C6038DE6AF18:VIW_MAIN.PERYR"; PERIOD_ID: ComboBox<'a> = "ZCMW_PERIOD_RE.ID_A61C4ED604A2BFC2A8F6C6038DE6AF18:VIW_MAIN.PERID"; @@ -24,12 +23,6 @@ impl<'a> CourseSchedule { MAIN_TABLE: SapTable<'a> = "SALV_WD_TABLE.ID_DE0D9128A4327646C94670E2A892C99C:VIEW_TABLE.SALV_WD_UIE_TABLE"; } - pub async fn new() -> Result { - Ok(CourseSchedule( - USaintApplication::new(Self::APP_NAME).await?, - )) - } - fn semester_to_key(period: SemesterType) -> &'static str { match period { SemesterType::One => "090", @@ -90,8 +83,7 @@ impl<'a> CourseSchedule { } pub fn read_edu_raw(&self) -> Result { - let body = self.body(); - let main_table = Self::MAIN_TABLE.from_body(body)?; + let main_table = Self::MAIN_TABLE.from_body(self.body())?; Ok(main_table) } } @@ -99,17 +91,23 @@ impl<'a> CourseSchedule { #[cfg(test)] mod test { use crate::{ - application::course_schedule::CourseSchedule, - webdynpro::{element::{ - complex::sap_table::cell::{SapTableCell, SapTableCellWrapper}, - selection::list_box::{item::ListBoxItemWrapper, ListBoxWrapper}, - ElementWrapper, - }, application::Application}, + application::{course_schedule::CourseSchedule, USaintApplicationBuilder}, + webdynpro::{ + application::Application, + element::{ + complex::sap_table::cell::{SapTableCell, SapTableCellWrapper}, + selection::list_box::{item::ListBoxItemWrapper, ListBoxWrapper}, + ElementWrapper, + }, + }, }; #[tokio::test] async fn examine_elements() { - let app = CourseSchedule::new().await.unwrap(); + let app = USaintApplicationBuilder::new() + .build_into::() + .await + .unwrap(); let ct_selector = scraper::Selector::parse("[ct]").unwrap(); for elem_ref in app.body().document().select(&ct_selector) { let elem = ElementWrapper::dyn_elem(elem_ref); @@ -121,7 +119,10 @@ mod test { #[tokio::test] async fn combobox_items() { - let app = CourseSchedule::new().await.unwrap(); + let app = USaintApplicationBuilder::new() + .build_into::() + .await + .unwrap(); let period_id_combobox = CourseSchedule::PERIOD_ID.from_body(app.body()).unwrap(); let listbox = period_id_combobox.item_list_box(app.body()).unwrap(); match listbox { @@ -146,7 +147,10 @@ mod test { #[tokio::test] async fn table_test() { - let mut app = CourseSchedule::new().await.unwrap(); + let mut app = USaintApplicationBuilder::new() + .build_into::() + .await + .unwrap(); app.load_edu().await.unwrap(); let table = app.read_edu_raw().unwrap(); if let Some(table) = table.table() { diff --git a/src/application/mod.rs b/src/application/mod.rs index 8dd6c7f..a43d966 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -6,14 +6,13 @@ use crate::{ utils::DEFAULT_USER_AGENT, webdynpro::{ application::{ - client::{body::Body, Client}, - Application, BasicApplication, + body::Body, client::Client, Application, BasicApplication, BasicApplicationBuilder, }, element::{ define_elements, system::{ClientInspector, Custom, CustomClientInfo, LoadingPlaceholder}, }, - error::{ClientError, WebDynproError}, + error::WebDynproError, event::Event, }, }; @@ -24,27 +23,21 @@ use crate::{ /// ```no_run /// # use std::sync::Arc; /// # use rusaint::define_usaint_application; -/// define_usaint_application(pub struct ExampleApplication); +/// # use rusaint::application::USaintApplicationBuilder; +/// define_usaint_application!(pub struct ExampleApplication<"ZCMW1001n">); /// /// impl<'a> ExampleApplication { -/// const APP_NAME: &str = "ZCMW1001n"; /// /// // 엘리먼트를 정의하는 매크로 /// define_elements! { /// // 담당자문의 정보에 해당하는 캡션의 ID 정의 /// CAPTION: Caption<'a> = "ZCMW_DEVINFO_RE.ID_D080C16F227F4D68751326DC40BB6BE0:MAIN.CAPTION" /// } -/// -/// pub async fn new(session: Arc) -> Result { -/// Ok(ExampleApplication( -/// USaintApplication::with_session(Self::APP_NAME, session).await?, -/// )) -/// } /// } /// /// async fn test() -> Result<(), dyn Error> { /// let session = Arc::new(USaintSession::with_password("20212345", "password").await?); -/// let app = ExampleApplication::new(session).await?; +/// let app = USaintApplicationBuilder::new().session(session).build_into::().await?; /// let caption = ExampleApplication::CAPTION.from_body(app.body())?; /// // Some("담당자문의 정보"); /// println!("{:?}", caption.text()); @@ -55,7 +48,7 @@ use crate::{ macro_rules! define_usaint_application { ( $(#[$attr:meta])* - $vis:vis struct $name:ident + $vis:vis struct $name:ident<$app_name:literal> ) => { $(#[$attr])* $vis struct $name($crate::application::USaintApplication); @@ -69,17 +62,27 @@ macro_rules! define_usaint_application { self.0.base_url() } - fn body(&self) -> &$crate::webdynpro::application::client::body::Body { + fn body(&self) -> &$crate::webdynpro::application::body::Body { self.0.body() } } + impl From for $name { + fn from(value: USaintApplication) -> Self { + $name(value) + } + } + + impl $crate::application::PredefinedUSaintApplication for $name { + const APP_NAME: &'static str = $app_name; + } + impl $name { #[allow(unused)] async fn send_events( &mut self, events: impl IntoIterator, - ) -> Result<(), WebDynproError> { + ) -> Result<(), $crate::webdynpro::error::WebDynproError> { self.0.send_events(events).await } } @@ -94,6 +97,12 @@ const INITIAL_CLIENT_DATA_WD02: &str = "ThemedTableRowHeight:25px"; /// u-saint에 접속하기 위한 기본 애플리케이션 pub struct USaintApplication(BasicApplication); +impl From for USaintApplication { + fn from(value: BasicApplication) -> Self { + USaintApplication(value) + } +} + impl Application for USaintApplication { fn name(&self) -> &str { self.0.name() @@ -117,51 +126,10 @@ impl<'a> USaintApplication { const CUSTOM: Custom = Custom::new(std::borrow::Cow::Borrowed("WD01")); - /// 새로운 u-saint 애플리케이션을 만듭니다. 이렇게 만들어진 애플리케이션은 익명 세션을 갖습니다. - /// ### 예시 - /// ``` - /// // 학기시간표 애플리케이션(로그인 없이 접근 가능) - /// let app = USaintApplication::new("ZCMW2100").await.unwrap(); - /// ``` - /// ```should_panic - /// // 학적정보 애플리케이션(세션이 없으므로 접속 불가) - /// let app = USaintApplication::new("ZCMW1001n").await.unwrap(); - /// ``` - /// 로그인된 세션이 필요한 애플리케이션에선 이용해서는 안됩니다. - pub async fn new(app_name: &str) -> Result { - let mut app = - USaintApplication(BasicApplication::new(SSU_WEBDYNPRO_BASE_URL, app_name).await?); - app.load_placeholder().await?; - Ok(app) + fn new(app: BasicApplication) -> USaintApplication { + USaintApplication(app) } - /// 세션이 포함된 u-saint 애플리케이션을 만듭니다. - /// ### 예시 - /// ```no_run - /// # use std::sync::Arc; - /// # use rusaint::USaintSession; - /// // 사용자 학번, 비밀번호로부터 세션 생성 - /// let session = Arc::new(USaintSession::with_password("20212345", "password!").await.unwrap()); - /// // 애플리케이션 생성(로그인 되었으므로 접속 가능) - /// let app = USaintApplication::with_session("ZCMW1001n", session).await.unwrap(); - /// ``` - pub async fn with_session( - app_name: &str, - session: Arc, - ) -> Result { - let base_url = Url::parse(SSU_WEBDYNPRO_BASE_URL).or(Err(ClientError::InvalidBaseUrl( - SSU_WEBDYNPRO_BASE_URL.to_string(), - )))?; - let r_client = reqwest::Client::builder() - .cookie_provider(session) - .user_agent(DEFAULT_USER_AGENT) - .build() - .unwrap(); - let client = Client::with_client(r_client, &base_url, app_name).await?; - let mut app = USaintApplication(BasicApplication::with_client(base_url, app_name, client)?); - app.load_placeholder().await?; - Ok(app) - } /// 이벤트를 서버에 전송합니다. [`send_events()`](crate::webdynpro::application::BasicApplication::send_events)를 참조하세요. pub async fn send_events( &mut self, @@ -191,6 +159,54 @@ impl<'a> USaintApplication { self.send_events(events).await } } + +/// 컴파일 타임에 정의하는 [`USaintApplication`]의 마커 +pub trait PredefinedUSaintApplication: From { + /// 정의된 애플리케이션의 이름 + const APP_NAME: &'static str; +} + +/// 새로운 [`USaintApplication`] 혹은 [`PredefinedUSaintApplication`]을 구현하는 애플리케이션을 생성하는 빌더 +pub struct USaintApplicationBuilder { + session: Option>, +} + +impl USaintApplicationBuilder { + /// 새로운 빌더를 만듭니다. + pub fn new() -> USaintApplicationBuilder { + USaintApplicationBuilder { session: None } + } + + /// 빌더에 [`USaintSession`]을 추가합니다. + pub fn session(mut self, session: Arc) -> USaintApplicationBuilder { + self.session = Some(session); + self + } + + /// 특정 [`PredefinedUSaintApplication`]을 만듭니다. + pub async fn build_into(self) -> Result { + let name = T::APP_NAME; + Ok(self.build(name).await?.into()) + } + + /// 애플리케이션 이름과 함께 [`USaintApplication`]을 생성합니다. + pub async fn build(self, name: &str) -> Result { + let mut builder = BasicApplicationBuilder::new(SSU_WEBDYNPRO_BASE_URL, name); + if let Some(session) = self.session { + let r_client = reqwest::Client::builder() + .cookie_provider(session) + .user_agent(DEFAULT_USER_AGENT) + .build() + .unwrap(); + let client = Client::with_client(r_client); + builder = builder.client(client); + } + let base_app = builder.build().await?; + let mut app = USaintApplication::new(base_app); + app.load_placeholder().await?; + Ok(app) + } +} /// 학생 성적 조회: [`CourseGrades`](course_grades::CourseGrades) pub mod course_grades; mod course_schedule; diff --git a/src/application/student_information.rs b/src/application/student_information.rs index fe4fd2e..b63a109 100644 --- a/src/application/student_information.rs +++ b/src/application/student_information.rs @@ -1,21 +1,6 @@ -use std::sync::Arc; - -use crate::{session::USaintSession, webdynpro::error::WebDynproError}; - use super::USaintApplication; -define_usaint_application!(pub struct StudentInformation); - -#[allow(unused)] -impl StudentInformation { - const APP_NAME: &str = "ZCMW1001n"; - - pub async fn new(session: Arc) -> Result { - Ok(StudentInformation( - USaintApplication::with_session(Self::APP_NAME, session).await?, - )) - } -} +define_usaint_application!(pub struct StudentInformation<"ZCMW1001n">); #[cfg(test)] mod test { @@ -23,7 +8,7 @@ mod test { use std::sync::{Arc, OnceLock}; use crate::{ - application::student_information::StudentInformation, + application::{student_information::StudentInformation, USaintApplicationBuilder}, session::USaintSession, webdynpro::{application::Application, element::ElementWrapper}, }; @@ -50,7 +35,11 @@ mod test { #[tokio::test] async fn examine_elements() { let session = get_session().await.unwrap(); - let app = StudentInformation::new(session).await.unwrap(); + let app = USaintApplicationBuilder::new() + .session(session) + .build_into::() + .await + .unwrap(); let ct_selector = scraper::Selector::parse("[ct]").unwrap(); for elem_ref in app.body().document().select(&ct_selector) { let elem = ElementWrapper::dyn_elem(elem_ref); diff --git a/src/webdynpro/application/client/body.rs b/src/webdynpro/application/body/mod.rs similarity index 95% rename from src/webdynpro/application/client/body.rs rename to src/webdynpro/application/body/mod.rs index 50cc7e2..66dc626 100644 --- a/src/webdynpro/application/client/body.rs +++ b/src/webdynpro/application/body/mod.rs @@ -20,7 +20,7 @@ pub(super) enum BodyUpdateType { #[derive(Debug)] #[allow(unused)] -pub(super) struct BodyUpdate { +pub(crate) struct BodyUpdate { update: Option, initialize_ids: Option, script_calls: Option>, @@ -128,15 +128,18 @@ impl BodyUpdate { pub struct Body { raw_body: String, document: Html, + sap_ssr_client: SapSsrClient, } impl Body { - pub(crate) fn new(body: String) -> Body { + pub(crate) fn new(body: String) -> Result { let document = Html::parse_document(&body); - Body { + let sap_ssr_client = Self::parse_sap_ssr_client(&document)?; + Ok(Body { raw_body: body, document, - } + sap_ssr_client, + }) } /// 페이지 도큐먼트의 HTML 텍스트를 반환합니다. @@ -149,8 +152,11 @@ impl Body { &self.document } - pub(super) fn parse_sap_ssr_client(&self) -> Result { - let document = &self.document; + pub(crate) fn ssr_client(&self) -> &SapSsrClient { + &self.sap_ssr_client + } + + fn parse_sap_ssr_client(document: &Html) -> Result { let selector = Selector::parse(r#"#sap\.client\.SsrClient\.form"#).unwrap(); let client_form = document .select(&selector) diff --git a/src/webdynpro/application/client/mod.rs b/src/webdynpro/application/client/mod.rs index 57af1b3..7f07e04 100644 --- a/src/webdynpro/application/client/mod.rs +++ b/src/webdynpro/application/client/mod.rs @@ -1,4 +1,7 @@ -use self::body::{Body, BodyUpdate}; +use super::{ + body::{Body, BodyUpdate}, + SapSsrClient, +}; use crate::{ utils::{default_header, DEFAULT_USER_AGENT}, webdynpro::{ @@ -13,17 +16,7 @@ use url::Url; /// WebDynpro 애플리케이션의 웹 요청 및 페이지 문서 처리를 담당하는 클라이언트 pub struct Client { client: reqwest::Client, - ssr_client: SapSsrClient, event_queue: EventQueue, - pub(super) body: Body, -} - -pub(super) struct SapSsrClient { - action: String, - charset: String, - wd_secure_id: String, - pub app_name: String, - use_beacon: bool, } pub(super) fn wd_xhr_header() -> HeaderMap { @@ -42,8 +35,9 @@ pub(super) fn wd_xhr_header() -> HeaderMap { } impl Client { + /// 새로운 클라이언트를 생성합니다. - pub async fn new(base_url: &Url, app_name: &str) -> Result { + pub fn new() -> Client { let jar: Arc = Arc::new(Jar::default()); let client = reqwest::Client::builder() .cookie_provider(jar) @@ -51,45 +45,53 @@ impl Client { .user_agent(DEFAULT_USER_AGENT) .build() .unwrap(); - Self::with_client(client, base_url, app_name).await + Self::with_client(client) } /// 임의의 reqwest::Client 와 함께 클라이언트를 생성합니다. - pub async fn with_client( - client: reqwest::Client, + pub fn with_client(client: reqwest::Client) -> Client { + Client { + client, + event_queue: EventQueue::new(), + } + } + + pub(crate) async fn navigate( + &mut self, base_url: &Url, app_name: &str, - ) -> Result { - let raw_body = client + ) -> Result { + let raw_body = self + .client .wd_navigate(base_url, app_name) .send() .await? .text() .await?; - let body = Body::new(raw_body); - let ssr_client = body.parse_sap_ssr_client()?; - let wd_client = Client { - client, - ssr_client, - event_queue: EventQueue::new(), - body, - }; - Ok(wd_client) + Ok(Body::new(raw_body)?) } pub(crate) fn add_event(&mut self, event: Event) { self.event_queue.add(event) } - pub(crate) async fn send_event(&mut self, base_url: &Url) -> Result<(), WebDynproError> { - let res = self.event_request(base_url).await?; - self.mutate_body(res) + pub(crate) async fn send_event( + &mut self, + base_url: &Url, + ssr_client: &SapSsrClient, + ) -> Result { + let res = self.event_request(base_url, ssr_client).await?; + Ok(BodyUpdate::new(&res)?) } - async fn event_request(&mut self, base_url: &Url) -> Result { + async fn event_request( + &mut self, + base_url: &Url, + ssr_client: &SapSsrClient, + ) -> Result { let res = self .client - .wd_xhr(base_url, &self.ssr_client, &mut self.event_queue)? + .wd_xhr(base_url, ssr_client, &mut self.event_queue)? .send() .await?; if !res.status().is_success() { @@ -97,12 +99,6 @@ impl Client { } Ok(res.text().await?) } - - fn mutate_body(&mut self, response: String) -> Result<(), WebDynproError> { - let body = &mut self.body; - let update = BodyUpdate::new(&response)?; - Ok(body.apply(update)?) - } } trait Requests { @@ -166,22 +162,22 @@ impl Requests for reqwest::Client { } } -/// WebDynpro의 페이지를 파싱, 업데이트하는 [`Body`] 구현 -pub mod body; - #[cfg(test)] mod test { use url::Url; use crate::webdynpro::application::client::Client; + #[tokio::test] async fn initial_load() { - let client = Client::new( - &Url::parse("https://ecc.ssu.ac.kr/sap/bc/webdynpro/SAP/").unwrap(), - "ZCMW2100", - ) - .await - .unwrap(); - assert_eq!(client.ssr_client.app_name, "ZCMW2100"); + let mut client = Client::new(); + let body = client + .navigate( + &Url::parse("https://ecc.ssu.ac.kr/sap/bc/webdynpro/SAP/").unwrap(), + "ZCMW2100", + ) + .await + .unwrap(); + assert_eq!(body.ssr_client().app_name, "ZCMW2100"); } } diff --git a/src/webdynpro/application/mod.rs b/src/webdynpro/application/mod.rs index ef24019..c409ad1 100644 --- a/src/webdynpro/application/mod.rs +++ b/src/webdynpro/application/mod.rs @@ -1,4 +1,7 @@ -use self::client::{body::Body, Client}; +use self::{ + body::{Body, BodyUpdate}, + client::Client, +}; use url::Url; use super::{ @@ -12,6 +15,7 @@ pub struct BasicApplication { base_url: Url, name: String, client: Client, + body: Body, } /// WebDynpro 애플리케이션의 기본 기능 @@ -48,7 +52,7 @@ impl Application for BasicApplication { } fn body(&self) -> &Body { - &self.client.body + &self.body } } @@ -57,36 +61,17 @@ impl<'a> BasicApplication { SSR_FORM: Form<'a> = "sap.client.SsrClient.form"; } - /// WebDynpro 애플리케이션이 제공되는 Base URL과 애플리케이션 이름을 제공하여 새로운 애플리케이션을 생성합니다. - /// ### 예시 - /// ``` - /// # tokio_test::block_on(async { - /// BasicApplication::new("https://ecc.ssu.ac.kr/sap/bc/webdynpro/SAP", "ZCMW2100").await.unwrap(); - /// # }) - /// ``` - pub async fn new(base_url_str: &str, name: &str) -> Result { - let base_url = Url::parse(base_url_str) - .or(Err(ClientError::InvalidBaseUrl(base_url_str.to_string())))?; - let client = Client::new(&base_url, name).await?; - Ok(Self::with_client(base_url, name, client)?) - } - - /// 임의의 WebDynpro [`Client`]와 함께 애플리케이션을 생성합니다. - /// ### 예시 - /// ``` - /// # tokio_test::block_on(async { - /// # use self::client::Client; - /// # use url::Url; - /// let url = Url::parse("https://ecc.ssu.ac.kr/sap/bc/webdynpro/SAP").unwrap(); - /// let client = Client::new(url, "ZCMW2100").await.unwrap(); - /// BasicApplication::with_client(url, "ZCMW2100", client).await.unwrap(); - /// # }) - /// ``` - pub fn with_client(base_url: Url, name: &str, client: Client) -> Result { + async fn with_client( + base_url: Url, + name: &str, + mut client: Client, + ) -> Result { + let body = { client.navigate(&base_url, name).await? }; Ok(BasicApplication { base_url, name: name.to_owned(), client, + body, }) } @@ -99,10 +84,10 @@ impl<'a> BasicApplication { /// ``` /// # tokio_test::block_on(async { /// # use std::sync::Arc; - /// # use rusaint::application::USaintApplication; + /// # use rusaint::application::USaintApplicationBuilder; /// # use rusaint::webdynpro::element::{ElementDef, selection::combo_box::ComboBox}; /// const PERIOD_YEAR: ElementDef<'_, ComboBox<'_>> = ElementDef::new("ZCMW_PERIOD_RE.ID_A61C4ED604A2BFC2A8F6C6038DE6AF18:VIW_MAIN.PERYR"); - /// # let app = USaintApplication::new("ZCMW2100").await.unwrap(); + /// # let app = USaintApplicationBuilder::new().name("ZCMW2100").await.unwrap(); /// let select_event = { /// // body를 참조하는 변수를 격리 /// let elem = PERIOD_YEAR.from_body(app.body()); @@ -115,8 +100,9 @@ impl<'a> BasicApplication { &mut self, events: impl IntoIterator, ) -> Result<(), WebDynproError> { + let body = self.body(); let form_req = Self::SSR_FORM - .from_body(&self.client.body)? + .from_body(body)? .request(false, "", "", false, false) .or(Err(ClientError::NoSuchForm( Self::SSR_FORM.id().to_string(), @@ -127,16 +113,70 @@ impl<'a> BasicApplication { self.client.add_event(event); self.client.add_event(form_req.to_owned()); } - { - self.client.send_event(&self.base_url).await?; - } + let update = { + self.client + .send_event(&self.base_url, self.body.ssr_client()) + .await? + }; + self.mutate_body(update)? } else { self.client.add_event(event); } } Ok(()) } + + fn mutate_body(&mut self, update: BodyUpdate) -> Result<(), WebDynproError> { + Ok(self.body.apply(update)?) + } +} + +/// [`BasicApplication`]을 생성하는 빌더 +pub struct BasicApplicationBuilder<'a> { + base_url: &'a str, + name: &'a str, + client: Option +} + +impl<'a> BasicApplicationBuilder<'a> { + + /// 새로운 [`BasicApplicationBuilder`]를 만듭니다. + pub fn new(base_url: &'a str, name: &'a str) -> BasicApplicationBuilder<'a> { + BasicApplicationBuilder { + base_url, + name, + client: None + } + } + + /// 애플리케이션에 임의의 [`Client`]를 추가합니다. + pub fn client(mut self, client: Client) -> BasicApplicationBuilder<'a> { + self.client = Some(client); + self + } + + /// 새로운 [`BasicApplication`]을 생성합니다. + pub async fn build(self) -> Result { + let client = match self.client { + Some(client) => { client }, + None => Client::new() + }; + let base_url = Url::parse(self.base_url) + .or(Err(ClientError::InvalidBaseUrl(self.base_url.to_string())))?; + Ok(BasicApplication::with_client(base_url, self.name, client).await?) + } +} + +pub(crate) struct SapSsrClient { + action: String, + charset: String, + wd_secure_id: String, + pub app_name: String, + use_beacon: bool, } /// WebDynpro 요청 및 문서 처리를 담당하는 클라이언트 pub mod client; + +/// WebDynpro의 페이지를 파싱, 업데이트하는 [`Body`] 구현 +pub mod body; diff --git a/src/webdynpro/element/mod.rs b/src/webdynpro/element/mod.rs index ad554fe..0889cf9 100644 --- a/src/webdynpro/element/mod.rs +++ b/src/webdynpro/element/mod.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value}; use self::{action::{Button, Link}, layout::{ButtonRow, Container, FlowLayout, Form, GridLayout, grid_layout::cell::GridLayoutCell, TabStrip, tab_strip::item::TabStripItem, PopupWindow, Tray, Scrollbar, ScrollContainer}, system::{ClientInspector, Custom, LoadingPlaceholder}, selection::{ComboBox, list_box::{ListBoxPopup, ListBoxPopupFiltered, ListBoxPopupJson, ListBoxPopupJsonFiltered, ListBoxMultiple, ListBoxSingle, item::{ListBoxItem, ListBoxActionItem}}}, graphic::Image, text::{InputField, Label, TextView, Caption}, complex::SapTable}; -use super::{event::{ucf_parameters::UcfParameters, Event, EventBuilder}, error::{ElementError, BodyError, WebDynproError}, application::client::body::Body}; +use super::{event::{ucf_parameters::UcfParameters, Event, EventBuilder}, error::{ElementError, BodyError, WebDynproError}, application::body::Body}; /// 버튼 등 기본적인 액션에 이용되는 엘리먼트 pub mod action; diff --git a/src/webdynpro/element/selection/combo_box.rs b/src/webdynpro/element/selection/combo_box.rs index 9125aed..8dde4a2 100644 --- a/src/webdynpro/element/selection/combo_box.rs +++ b/src/webdynpro/element/selection/combo_box.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, cell::OnceCell, collections::HashMap}; use crate::webdynpro::error::{BodyError, WebDynproError}; -use crate::webdynpro::{application::client::body::Body, error::ElementError, event::Event}; +use crate::webdynpro::{application::body::Body, error::ElementError, event::Event}; use crate::webdynpro::element::{ define_element_interactable, Element, ElementWrapper, Interactable, @@ -60,12 +60,13 @@ impl<'a> ComboBox<'a> { /// [`ComboBox`]의 선택지 역할을 하는 [`ListBox`](super::list_box::ListBox) 엘리먼트를 가져옵니다. pub fn item_list_box(&self, body: &'a Body) -> Result, WebDynproError> { - let listbox_id = - self.lsdata().item_list_box_id() - .ok_or(ElementError::NoSuchData { - element: self.id().to_string(), - field: "item_list_box_id".to_string(), - })?; + let listbox_id = self + .lsdata() + .item_list_box_id() + .ok_or(ElementError::NoSuchData { + element: self.id().to_string(), + field: "item_list_box_id".to_string(), + })?; let selector = scraper::Selector::parse(format!(r#"[id="{}"]"#, listbox_id).as_str()) .or(Err(ElementError::InvalidId(listbox_id.to_owned())))?; let elem = body diff --git a/tests/application/course_grades.rs b/tests/application/course_grades.rs index f8b06ef..25f00bd 100644 --- a/tests/application/course_grades.rs +++ b/tests/application/course_grades.rs @@ -3,7 +3,10 @@ use std::sync::{Arc, OnceLock}; use dotenv::dotenv; use rusaint::{ - application::course_grades::{model::CourseType, CourseGrades}, + application::{ + course_grades::{model::CourseType, CourseGrades}, + USaintApplicationBuilder, + }, model::SemesterType, USaintSession, }; @@ -31,17 +34,28 @@ async fn get_session() -> Result> { #[serial] async fn summaries() { let session = get_session().await.unwrap(); - let mut app = CourseGrades::new(session).await.unwrap(); + let mut app = USaintApplicationBuilder::new() + .session(session) + .build_into::() + .await + .unwrap(); let recorded_summary = app.recorded_summary(CourseType::Bachelor).await.unwrap(); println!("Recorded: {:?}", recorded_summary); - let certificated_summary = app.certificated_summary(CourseType::Bachelor).await.unwrap(); + let certificated_summary = app + .certificated_summary(CourseType::Bachelor) + .await + .unwrap(); println!("Certificated: {:?}", certificated_summary); } #[tokio::test] #[serial] async fn semesters() { let session = get_session().await.unwrap(); - let mut app = CourseGrades::new(session).await.unwrap(); + let mut app = USaintApplicationBuilder::new() + .session(session) + .build_into::() + .await + .unwrap(); let semesters = app.semesters(CourseType::Bachelor).await.unwrap(); println!("{:?}", semesters); assert!(!semesters.is_empty()); @@ -51,7 +65,11 @@ async fn semesters() { #[serial] async fn classes_with_detail() { let session = get_session().await.unwrap(); - let mut app = CourseGrades::new(session).await.unwrap(); + let mut app = USaintApplicationBuilder::new() + .session(session) + .build_into::() + .await + .unwrap(); let details = app .classes(CourseType::Bachelor, "2022", SemesterType::Two, true) .await diff --git a/tests/webdynpro/element/button.rs b/tests/webdynpro/element/button.rs index 5d8dcab..0ebdb35 100644 --- a/tests/webdynpro/element/button.rs +++ b/tests/webdynpro/element/button.rs @@ -6,7 +6,7 @@ use rusaint::{ text::TextView, }, error::WebDynproError, application::Application, - }, + }, application::USaintApplicationBuilder, }; use crate::get_session; @@ -45,6 +45,6 @@ impl<'a> EventTestSuite { #[tokio::test] async fn test_button_events() { let session = get_session().await.unwrap(); - let mut suite = EventTestSuite::new(session).await.unwrap(); + let mut suite = USaintApplicationBuilder::new().session(session).build_into::().await.unwrap(); suite.test_button().await.unwrap(); } diff --git a/tests/webdynpro/element/mod.rs b/tests/webdynpro/element/mod.rs index a96e9ae..9bede32 100644 --- a/tests/webdynpro/element/mod.rs +++ b/tests/webdynpro/element/mod.rs @@ -1,20 +1,5 @@ -use std::sync::Arc; +use rusaint::{application::USaintApplication, define_usaint_application}; -use rusaint::{ - application::USaintApplication, define_usaint_application, webdynpro::error::WebDynproError, - USaintSession, -}; - -define_usaint_application!(pub(crate) struct EventTestSuite); - -impl<'a> EventTestSuite { - const APP_NAME: &str = "WDR_TEST_EVENTS"; - - pub async fn new(session: Arc) -> Result { - Ok(EventTestSuite( - USaintApplication::with_session(Self::APP_NAME, session).await?, - )) - } -} +define_usaint_application!(pub(crate) struct EventTestSuite<"WDR_TEST_EVENTS">); mod button;