363 lines
10 KiB
Go
363 lines
10 KiB
Go
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
|
|
}
|