author | wenzelm |
Mon, 21 Feb 2022 21:19:45 +0100 | |
changeset 75121 | 2efbb4e813ad |
parent 75120 | 488c7e8923b2 |
child 75145 | e721880779be |
permissions | -rw-r--r-- |
65077 | 1 |
/* Title: Pure/General/http.scala |
63823 | 2 |
Author: Makarius |
3 |
||
73438 | 4 |
HTTP client and server support. |
63823 | 5 |
*/ |
6 |
||
7 |
package isabelle |
|
8 |
||
9 |
||
73417 | 10 |
import java.io.{File => JFile} |
75104 | 11 |
import java.nio.file.Files |
75107 | 12 |
import java.net.{InetSocketAddress, URI, URL, HttpURLConnection} |
63823 | 13 |
import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer} |
14 |
||
15 |
||
65077 | 16 |
object HTTP |
63823 | 17 |
{ |
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
18 |
/** content **/ |
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
19 |
|
75106 | 20 |
object Content |
21 |
{ |
|
22 |
val mime_type_bytes: String = "application/octet-stream" |
|
23 |
val mime_type_text: String = "text/plain; charset=utf-8" |
|
24 |
val mime_type_html: String = "text/html; charset=utf-8" |
|
73417 | 25 |
|
75106 | 26 |
val default_mime_type: String = mime_type_bytes |
27 |
val default_encoding: String = UTF8.charset_name |
|
28 |
||
29 |
def apply( |
|
30 |
bytes: Bytes, |
|
31 |
file_name: String = "", |
|
32 |
mime_type: String = default_mime_type, |
|
33 |
encoding: String = default_encoding, |
|
34 |
elapsed_time: Time = Time.zero): Content = |
|
35 |
new Content(bytes, file_name, mime_type, encoding, elapsed_time) |
|
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
36 |
|
75106 | 37 |
def read(file: JFile): Content = |
38 |
{ |
|
39 |
val bytes = Bytes.read(file) |
|
40 |
val file_name = file.getName |
|
41 |
val mime_type = Option(Files.probeContentType(file.toPath)).getOrElse(default_mime_type) |
|
42 |
apply(bytes, file_name = file_name, mime_type = mime_type) |
|
43 |
} |
|
44 |
||
45 |
def read(path: Path): Content = read(path.file) |
|
46 |
} |
|
47 |
||
48 |
class Content private( |
|
49 |
val bytes: Bytes, |
|
50 |
val file_name: String, |
|
51 |
val mime_type: String, |
|
52 |
val encoding: String, |
|
53 |
val elapsed_time: Time) |
|
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
54 |
{ |
73440 | 55 |
def text: String = new String(bytes.array, encoding) |
74945 | 56 |
def json: JSON.T = JSON.parse(text) |
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
57 |
} |
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
58 |
|
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
59 |
|
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
60 |
|
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
61 |
/** client **/ |
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
62 |
|
73417 | 63 |
val NEWLINE: String = "\r\n" |
64 |
||
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
65 |
object Client |
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
66 |
{ |
73439 | 67 |
val default_timeout: Time = Time.seconds(180) |
73422 | 68 |
|
69 |
def open_connection(url: URL, |
|
70 |
timeout: Time = default_timeout, |
|
71 |
user_agent: String = ""): HttpURLConnection = |
|
73421 | 72 |
{ |
73 |
url.openConnection match { |
|
74 |
case connection: HttpURLConnection => |
|
73422 | 75 |
if (0 < timeout.ms && timeout.ms <= Integer.MAX_VALUE) { |
76 |
val ms = timeout.ms.toInt |
|
77 |
connection.setConnectTimeout(ms) |
|
78 |
connection.setReadTimeout(ms) |
|
79 |
} |
|
73421 | 80 |
proper_string(user_agent).foreach(s => connection.setRequestProperty("User-Agent", s)) |
81 |
connection |
|
82 |
case _ => error("Bad URL (not HTTP): " + quote(url.toString)) |
|
83 |
} |
|
84 |
} |
|
73417 | 85 |
|
73421 | 86 |
def get_content(connection: HttpURLConnection): Content = |
73417 | 87 |
{ |
88 |
val Charset = """.*\bcharset="?([\S^"]+)"?.*""".r |
|
73429
8970081c6500
elapsed time to download content (and for the server to provide content);
wenzelm
parents:
73425
diff
changeset
|
89 |
|
8970081c6500
elapsed time to download content (and for the server to provide content);
wenzelm
parents:
73425
diff
changeset
|
90 |
val start = Time.now() |
73417 | 91 |
using(connection.getInputStream)(stream => |
92 |
{ |
|
93 |
val bytes = Bytes.read_stream(stream, hint = connection.getContentLength) |
|
73429
8970081c6500
elapsed time to download content (and for the server to provide content);
wenzelm
parents:
73425
diff
changeset
|
94 |
val stop = Time.now() |
8970081c6500
elapsed time to download content (and for the server to provide content);
wenzelm
parents:
73425
diff
changeset
|
95 |
|
73417 | 96 |
val file_name = Url.file_name(connection.getURL) |
75106 | 97 |
val mime_type = Option(connection.getContentType).getOrElse(Content.default_mime_type) |
73417 | 98 |
val encoding = |
99 |
(connection.getContentEncoding, mime_type) match { |
|
100 |
case (enc, _) if enc != null => enc |
|
101 |
case (_, Charset(enc)) => enc |
|
75106 | 102 |
case _ => Content.default_encoding |
73417 | 103 |
} |
73429
8970081c6500
elapsed time to download content (and for the server to provide content);
wenzelm
parents:
73425
diff
changeset
|
104 |
Content(bytes, file_name = file_name, mime_type = mime_type, |
8970081c6500
elapsed time to download content (and for the server to provide content);
wenzelm
parents:
73425
diff
changeset
|
105 |
encoding = encoding, elapsed_time = stop - start) |
73417 | 106 |
}) |
107 |
} |
|
108 |
||
73422 | 109 |
def get(url: URL, timeout: Time = default_timeout, user_agent: String = ""): Content = |
110 |
get_content(open_connection(url, timeout = timeout, user_agent = user_agent)) |
|
73417 | 111 |
|
73422 | 112 |
def post(url: URL, parameters: List[(String, Any)], |
113 |
timeout: Time = default_timeout, |
|
114 |
user_agent: String = ""): Content = |
|
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
115 |
{ |
73422 | 116 |
val connection = open_connection(url, timeout = timeout, user_agent = user_agent) |
73421 | 117 |
connection.setRequestMethod("POST") |
118 |
connection.setDoOutput(true) |
|
73417 | 119 |
|
74094 | 120 |
val boundary = UUID.random().toString |
73421 | 121 |
connection.setRequestProperty( |
122 |
"Content-Type", "multipart/form-data; boundary=" + quote(boundary)) |
|
73417 | 123 |
|
73421 | 124 |
using(connection.getOutputStream)(out => |
125 |
{ |
|
126 |
def output(s: String): Unit = out.write(UTF8.bytes(s)) |
|
127 |
def output_newline(n: Int = 1): Unit = (1 to n).foreach(_ => output(NEWLINE)) |
|
128 |
def output_boundary(end: Boolean = false): Unit = |
|
129 |
output("--" + boundary + (if (end) "--" else "") + NEWLINE) |
|
130 |
def output_name(name: String): Unit = |
|
131 |
output("Content-Disposition: form-data; name=" + quote(name)) |
|
132 |
def output_value(value: Any): Unit = |
|
133 |
{ |
|
134 |
output_newline(2) |
|
135 |
output(value.toString) |
|
136 |
} |
|
137 |
def output_content(content: Content): Unit = |
|
138 |
{ |
|
139 |
proper_string(content.file_name).foreach(s => output("; filename=" + quote(s))) |
|
140 |
output_newline() |
|
141 |
proper_string(content.mime_type).foreach(s => output("Content-Type: " + s)) |
|
142 |
output_newline(2) |
|
143 |
content.bytes.write_stream(out) |
|
144 |
} |
|
73417 | 145 |
|
73421 | 146 |
output_newline(2) |
73417 | 147 |
|
73421 | 148 |
for { (name, value) <- parameters } { |
149 |
output_boundary() |
|
150 |
output_name(name) |
|
151 |
value match { |
|
152 |
case content: Content => output_content(content) |
|
75106 | 153 |
case file: JFile => output_content(Content.read(file)) |
154 |
case path: Path => output_content(Content.read(path)) |
|
73421 | 155 |
case _ => output_value(value) |
156 |
} |
|
157 |
output_newline() |
|
158 |
} |
|
159 |
output_boundary(end = true) |
|
160 |
out.flush() |
|
161 |
}) |
|
73417 | 162 |
|
73421 | 163 |
get_content(connection) |
73416
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
164 |
} |
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
165 |
} |
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
166 |
|
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
167 |
|
08aa4c1ed579
clarified signature: more explicit HTTP operations;
wenzelm
parents:
73413
diff
changeset
|
168 |
|
65081 | 169 |
/** server **/ |
170 |
||
75107 | 171 |
/* request */ |
172 |
||
75108 | 173 |
def url_path(names: String*): String = |
174 |
names.iterator.flatMap(a => if (a.isEmpty) None else Some("/" + a)).mkString |
|
175 |
||
176 |
class Request private[HTTP]( |
|
177 |
val server: String, |
|
178 |
val service: String, |
|
179 |
val uri: URI, |
|
180 |
val input: Bytes) |
|
75107 | 181 |
{ |
75108 | 182 |
def home: String = url_path(server, service) |
75107 | 183 |
def root: String = home + "/" |
184 |
def query: String = home + "?" |
|
185 |
||
75109 | 186 |
def toplevel: Boolean = uri_name == home || uri_name == root |
187 |
||
188 |
val uri_name: String = uri.toString |
|
75107 | 189 |
|
190 |
def uri_path: Option[Path] = |
|
191 |
for { |
|
75111 | 192 |
s <- Option(uri.getPath).flatMap(Library.try_unprefix(root, _)) |
193 |
if Path.is_wellformed(s) |
|
194 |
p = Path.explode(s) if p.all_basic |
|
75107 | 195 |
} yield p |
196 |
||
75112 | 197 |
def decode_query: Option[String] = |
198 |
Library.try_unprefix(query, uri_name).map(Url.decode) |
|
199 |
||
75107 | 200 |
def decode_properties: Properties.T = |
201 |
space_explode('&', input.text).map( |
|
202 |
{ |
|
203 |
case Properties.Eq(a, b) => Url.decode(a) -> Url.decode(b) |
|
204 |
case s => error("Malformed key-value pair in HTTP/POST: " + quote(s)) |
|
205 |
}) |
|
206 |
} |
|
207 |
||
208 |
||
65078 | 209 |
/* response */ |
210 |
||
211 |
object Response |
|
212 |
{ |
|
213 |
def apply( |
|
214 |
bytes: Bytes = Bytes.empty, |
|
75106 | 215 |
content_type: String = Content.mime_type_bytes): Response = |
65078 | 216 |
new Response(bytes, content_type) |
217 |
||
218 |
val empty: Response = apply() |
|
75106 | 219 |
def text(s: String): Response = apply(Bytes(s), Content.mime_type_text) |
220 |
def html(s: String): Response = apply(Bytes(s), Content.mime_type_html) |
|
75105
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
221 |
|
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
222 |
def content(content: Content): Response = apply(content.bytes, content.mime_type) |
75106 | 223 |
def read(file: JFile): Response = content(Content.read(file)) |
224 |
def read(path: Path): Response = content(Content.read(path)) |
|
65078 | 225 |
} |
226 |
||
75107 | 227 |
class Response private[HTTP](val output: Bytes, val content_type: String) |
65078 | 228 |
{ |
75107 | 229 |
override def toString: String = output.toString |
65078 | 230 |
|
75107 | 231 |
def write(http: HttpExchange, code: Int): Unit = |
65078 | 232 |
{ |
75107 | 233 |
http.getResponseHeaders.set("Content-Type", content_type) |
234 |
http.sendResponseHeaders(code, output.length.toLong) |
|
235 |
using(http.getResponseBody)(output.write_stream) |
|
65078 | 236 |
} |
237 |
} |
|
238 |
||
239 |
||
75107 | 240 |
/* service */ |
66207 | 241 |
|
75113 | 242 |
abstract class Service(val service: String, method: String = "GET") |
73413 | 243 |
{ |
75113 | 244 |
override def toString: String = service |
245 |
||
246 |
def apply(request: Request): Option[Response] |
|
247 |
||
248 |
def context(server: String): String = proper_string(url_path(server, service)).getOrElse("/") |
|
249 |
||
250 |
def handler(server: String): HttpHandler = (http: HttpExchange) => { |
|
251 |
val uri = http.getRequestURI |
|
252 |
val input = using(http.getRequestBody)(Bytes.read_stream(_)) |
|
253 |
if (http.getRequestMethod == method) { |
|
254 |
val request = new Request(server, service, uri, input) |
|
255 |
Exn.capture(apply(request)) match { |
|
256 |
case Exn.Res(Some(response)) => |
|
257 |
response.write(http, 200) |
|
258 |
case Exn.Res(None) => |
|
259 |
Response.empty.write(http, 404) |
|
260 |
case Exn.Exn(ERROR(msg)) => |
|
261 |
Response.text(Output.error_message_text(msg)).write(http, 500) |
|
262 |
case Exn.Exn(exn) => throw exn |
|
75107 | 263 |
} |
75113 | 264 |
} |
265 |
else Response.empty.write(http, 400) |
|
75107 | 266 |
} |
73413 | 267 |
} |
66207 | 268 |
|
65077 | 269 |
|
270 |
/* server */ |
|
271 |
||
75107 | 272 |
class Server private[HTTP](val name: String, val http_server: HttpServer) |
65077 | 273 |
{ |
75107 | 274 |
def += (service: Service): Unit = |
75108 | 275 |
http_server.createContext(service.context(name), service.handler(name)) |
75107 | 276 |
def -= (service: Service): Unit = |
75108 | 277 |
http_server.removeContext(service.context(name)) |
65077 | 278 |
|
73439 | 279 |
def start(): Unit = http_server.start() |
73367 | 280 |
def stop(): Unit = http_server.stop(0) |
65077 | 281 |
|
282 |
def address: InetSocketAddress = http_server.getAddress |
|
75107 | 283 |
|
75108 | 284 |
def url: String = "http://" + address.getHostName + ":" + address.getPort + url_path(name) |
65077 | 285 |
override def toString: String = url |
286 |
} |
|
287 |
||
75107 | 288 |
def server( |
289 |
name: String = UUID.random().toString, |
|
290 |
services: List[Service] = isabelle_services): Server = |
|
63823 | 291 |
{ |
69463 | 292 |
val http_server = HttpServer.create(new InetSocketAddress(isabelle.Server.localhost, 0), 0) |
65077 | 293 |
http_server.setExecutor(null) |
65076 | 294 |
|
75107 | 295 |
val server = new Server(name, http_server) |
296 |
for (service <- services) server += service |
|
65077 | 297 |
server |
63823 | 298 |
} |
65079 | 299 |
|
300 |
||
65081 | 301 |
|
75107 | 302 |
/** Isabelle services **/ |
65081 | 303 |
|
75113 | 304 |
def isabelle_services: List[Service] = |
75119 | 305 |
List(new Welcome(), new Fonts(), new PDFjs(), new Docs()) |
65081 | 306 |
|
307 |
||
308 |
/* welcome */ |
|
309 |
||
75113 | 310 |
class Welcome(name: String = "") extends Service(name) |
311 |
{ |
|
312 |
def apply(request: Request): Option[Response] = |
|
313 |
if (request.toplevel) { |
|
314 |
Some(Response.text("Welcome to " + Isabelle_System.identification())) |
|
315 |
} |
|
316 |
else None |
|
317 |
} |
|
65081 | 318 |
|
319 |
||
320 |
/* fonts */ |
|
65079 | 321 |
|
75113 | 322 |
class Fonts(name: String = "fonts") extends Service(name) |
323 |
{ |
|
324 |
private lazy val html_fonts: List[Isabelle_Fonts.Entry] = |
|
325 |
Isabelle_Fonts.fonts(hidden = true) |
|
65079 | 326 |
|
75113 | 327 |
def apply(request: Request): Option[Response] = |
328 |
if (request.toplevel) { |
|
329 |
Some(Response.text(cat_lines(html_fonts.map(entry => entry.path.file_name)))) |
|
330 |
} |
|
331 |
else { |
|
332 |
request.uri_path.flatMap(path => |
|
333 |
html_fonts.collectFirst( |
|
334 |
{ case entry if path == entry.path.base => Response(entry.bytes) } |
|
335 |
)) |
|
336 |
} |
|
337 |
} |
|
75105
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
338 |
|
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
339 |
|
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
340 |
/* pdfjs */ |
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
341 |
|
75113 | 342 |
class PDFjs(name: String = "pdfjs") extends Service(name) |
343 |
{ |
|
344 |
def apply(request: Request): Option[Response] = |
|
75105
03115c9eea00
support for PDF.js: platform-independent PDF viewer;
wenzelm
parents:
75104
diff
changeset
|
345 |
for { |
75107 | 346 |
p <- request.uri_path |
75117 | 347 |
path = Path.explode("$ISABELLE_PDFJS_HOME") + p if path.is_file |
348 |
s = p.implode if s.startsWith("build/") || s.startsWith("web/") |
|
75113 | 349 |
} yield Response.read(path) |
350 |
} |
|
75119 | 351 |
|
352 |
||
353 |
/* docs */ |
|
354 |
||
355 |
class Docs(name: String = "docs") extends PDFjs(name) |
|
356 |
{ |
|
357 |
private val doc_contents = isabelle.Doc.main_contents() |
|
358 |
||
75121 | 359 |
// example: .../docs/web/viewer.html?file=system.pdf |
75119 | 360 |
def doc_request(request: Request): Option[Response] = |
361 |
for { |
|
362 |
p <- request.uri_path if p.is_pdf |
|
75121 | 363 |
s = p.implode if s.startsWith("web/") |
75119 | 364 |
name = p.base.split_ext._1.implode |
365 |
doc <- doc_contents.docs.find(_.name == name) |
|
75120 | 366 |
} yield Response.read(doc.path) |
75119 | 367 |
|
368 |
override def apply(request: Request): Option[Response] = |
|
369 |
doc_request(request) orElse super.apply(request) |
|
370 |
} |
|
371 |
} |