package usos import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" _ "time/tzdata" ) const ( Scopes = "studies|offline_access" activityFields = "type|start_time|end_time|name|url|course_id|course_name|classtype_name|lecturer_ids|group_number|building_name|building_id|room_number|unit_id|sm_id" commonFields = "type|start_time|end_time|name|url" timeLayout = "2006-01-02 15:04:05" dateLayout = "2006-01-02" responseLimit = 8 << 20 lecturerBatch = 20 ) var ( ErrUnauthorized = errors.New("usos: access token rejected") Location = mustLocation("Europe/Warsaw") ) func mustLocation(name string) *time.Location { location, err := time.LoadLocation(name) if err != nil { panic(err) } return location } type Error struct { Status int Message string } func (e *Error) Error() string { return fmt.Sprintf("usos: http %d: %s", e.Status, e.Message) } type Token struct { Key string Secret string } type Client struct { BaseURL string Key string Secret string HTTP *http.Client } func New(baseURL, key, secret string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), Key: key, Secret: secret, HTTP: &http.Client{Timeout: 30 * time.Second}, } } func (c *Client) call(ctx context.Context, method string, token *Token, params url.Values) ([]byte, error) { endpoint := c.BaseURL + "/services/" + method header, body := c.authorization(endpoint, token, params) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body.Encode())) if err != nil { return nil, err } req.Header.Set("Authorization", header) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("usos: %s: %w", method, err) } defer res.Body.Close() data, err := io.ReadAll(io.LimitReader(res.Body, responseLimit)) if err != nil { return nil, fmt.Errorf("usos: %s: %w", method, err) } if res.StatusCode == http.StatusUnauthorized { return nil, ErrUnauthorized } if res.StatusCode != http.StatusOK { var failure struct { Message string `json:"message"` } _ = json.Unmarshal(data, &failure) return nil, &Error{Status: res.StatusCode, Message: method + ": " + failure.Message} } return data, nil } func (c *Client) tokenCall(ctx context.Context, method string, token *Token, params url.Values) (Token, error) { data, err := c.call(ctx, method, token, params) if err != nil { return Token{}, err } values, err := url.ParseQuery(strings.TrimSpace(string(data))) if err != nil || values.Get("oauth_token") == "" || values.Get("oauth_token_secret") == "" { return Token{}, &Error{Status: http.StatusOK, Message: method + ": malformed token response"} } return Token{Key: values.Get("oauth_token"), Secret: values.Get("oauth_token_secret")}, nil } func (c *Client) RequestToken(ctx context.Context, callback string) (Token, error) { return c.tokenCall(ctx, "oauth/request_token", nil, url.Values{"oauth_callback": {callback}, "scopes": {Scopes}}) } func (c *Client) AuthorizeURL(request Token) string { return c.BaseURL + "/services/oauth/authorize?oauth_token=" + url.QueryEscape(request.Key) } func (c *Client) AccessToken(ctx context.Context, request Token, verifier string) (Token, error) { return c.tokenCall(ctx, "oauth/access_token", &request, url.Values{"oauth_verifier": {verifier}}) } func (c *Client) Revoke(ctx context.Context, token Token) error { _, err := c.call(ctx, "oauth/revoke_token", &token, nil) return err } type Person struct { ID Scalar `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Titles struct { Before string `json:"before"` After string `json:"after"` } `json:"titles"` } func (p Person) Name() string { return strings.Join(strings.Fields(p.Titles.Before+" "+p.FirstName+" "+p.LastName+" "+p.Titles.After), " ") } func (c *Client) Me(ctx context.Context, token Token) (Person, error) { var person Person data, err := c.call(ctx, "users/user", &token, url.Values{"fields": {"id|first_name|last_name"}}) if err != nil { return person, err } if err := json.Unmarshal(data, &person); err != nil || person.ID == "" { return person, &Error{Status: http.StatusOK, Message: "users/user: malformed response"} } return person, nil } func (c *Client) Lecturers(ctx context.Context, ids []string) (map[string]string, error) { names := map[string]string{} for start := 0; start < len(ids); start += lecturerBatch { batch := ids[start:min(start+lecturerBatch, len(ids))] data, err := c.call(ctx, "users/users", nil, url.Values{ "user_ids": {strings.Join(batch, "|")}, "fields": {"id|first_name|last_name|titles"}, }) if err != nil { return names, err } var people map[string]*Person if err := json.Unmarshal(data, &people); err != nil { return names, &Error{Status: http.StatusOK, Message: "users/users: malformed response"} } for id, person := range people { if person != nil { names[id] = person.Name() } } } return names, nil } type Building struct { Name LangDict `json:"name"` Address string `json:"postal_address"` } func (c *Client) Building(ctx context.Context, id string) (Building, error) { var building Building data, err := c.call(ctx, "geo/building2", nil, url.Values{"building_id": {id}, "fields": {"id|name|postal_address"}}) if err != nil { return building, err } if err := json.Unmarshal(data, &building); err != nil { return building, &Error{Status: http.StatusOK, Message: "geo/building2: malformed response"} } return building, nil } type Term struct { Start time.Time Finish time.Time } func (c *Client) Terms(ctx context.Context, startsBy, finishesFrom time.Time) ([]Term, error) { data, err := c.call(ctx, "terms/search", nil, url.Values{ "max_start_date": {startsBy.Format(dateLayout)}, "min_finish_date": {finishesFrom.Format(dateLayout)}, }) if err != nil { return nil, err } var raw []struct { Start string `json:"start_date"` Finish string `json:"finish_date"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, &Error{Status: http.StatusOK, Message: "terms/search: malformed response"} } terms := make([]Term, 0, len(raw)) for _, item := range raw { start, startErr := time.ParseInLocation(dateLayout, item.Start, Location) finish, finishErr := time.ParseInLocation(dateLayout, item.Finish, Location) if startErr == nil && finishErr == nil { terms = append(terms, Term{Start: start, Finish: finish}) } } return terms, nil } type Activity struct { Type string Start time.Time End time.Time Name string URL string CourseID string CourseName string ClasstypeName string LecturerIDs []string GroupNumber string BuildingName string BuildingID string RoomNumber string UnitID string MeetingID string } type rawActivity struct { Type string `json:"type"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` Name LangDict `json:"name"` URL Scalar `json:"url"` CourseID Scalar `json:"course_id"` CourseName LangDict `json:"course_name"` ClasstypeName LangDict `json:"classtype_name"` LecturerIDs []Scalar `json:"lecturer_ids"` GroupNumber Scalar `json:"group_number"` BuildingName LangDict `json:"building_name"` BuildingID Scalar `json:"building_id"` RoomNumber Scalar `json:"room_number"` UnitID Scalar `json:"unit_id"` MeetingID Scalar `json:"sm_id"` } func (c *Client) Timetable(ctx context.Context, token Token, start time.Time, days int) ([]Activity, error) { params := url.Values{ "start": {start.Format(dateLayout)}, "days": {strconv.Itoa(days)}, "fields": {activityFields}, } data, err := c.call(ctx, "tt/user", &token, params) var failure *Error if errors.As(err, &failure) && failure.Status >= http.StatusInternalServerError { params.Set("fields", commonFields) data, err = c.call(ctx, "tt/user", &token, params) } if err != nil { return nil, err } var raw []rawActivity if err := json.Unmarshal(data, &raw); err != nil { return nil, &Error{Status: http.StatusOK, Message: "tt/user: malformed response"} } activities := make([]Activity, 0, len(raw)) for _, item := range raw { begin, beginErr := time.ParseInLocation(timeLayout, item.StartTime, Location) end, endErr := time.ParseInLocation(timeLayout, item.EndTime, Location) if beginErr != nil || endErr != nil { continue } lecturers := make([]string, 0, len(item.LecturerIDs)) for _, id := range item.LecturerIDs { lecturers = append(lecturers, string(id)) } activities = append(activities, Activity{ Type: item.Type, Start: begin, End: end, Name: item.Name.String(), URL: string(item.URL), CourseID: string(item.CourseID), CourseName: item.CourseName.String(), ClasstypeName: item.ClasstypeName.String(), LecturerIDs: lecturers, GroupNumber: string(item.GroupNumber), BuildingName: item.BuildingName.String(), BuildingID: string(item.BuildingID), RoomNumber: string(item.RoomNumber), UnitID: string(item.UnitID), MeetingID: string(item.MeetingID), }) } return activities, nil } type Scalar string func (s *Scalar) UnmarshalJSON(data []byte) error { data = bytes.TrimSpace(data) if len(data) == 0 || string(data) == "null" { *s = "" return nil } if data[0] == '"' { var text string if err := json.Unmarshal(data, &text); err != nil { return err } *s = Scalar(text) return nil } if data[0] == '{' || data[0] == '[' { *s = "" return nil } *s = Scalar(data) return nil } type LangDict struct { PL string EN string } func (l *LangDict) UnmarshalJSON(data []byte) error { var raw struct { PL Scalar `json:"pl"` EN Scalar `json:"en"` } if json.Unmarshal(data, &raw) == nil { l.PL, l.EN = strings.TrimSpace(string(raw.PL)), strings.TrimSpace(string(raw.EN)) } return nil } func (l LangDict) String() string { if l.PL != "" { return l.PL } return l.EN }