Skip to content

Commit

Permalink
Break into modules etc.
Browse files Browse the repository at this point in the history
  • Loading branch information
dtcristo committed Apr 28, 2023
1 parent 8117344 commit f7b8ebc
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 238 deletions.
27 changes: 0 additions & 27 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ futures = "0.3"
reqwest = { version = "0.11", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
strum_macros = "0.24"
# strum_macros = "0.24"
tokio = { version = "1", features = ["full"] }
# tower = { version = "0.4", features = ["full"] }
tower-http = { version = "0.4", features = ["full"] }

[dev-dependencies]
anyhow = "1"
# anyhow = "1"
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ COPY . .
RUN cargo build --release --bin gpt-html

FROM alpine AS runtime
ENV COMMIT_SHA=$COMMIT_SHA
ARG COMMIT_SHA
ENV COMMIT_SHA="$COMMIT_SHA"
ENV DOCKER="true"
WORKDIR /app
COPY --from=build /app/target/release/gpt-html .
EXPOSE 9292
Expand Down
19 changes: 18 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
@@ -1,2 +1,19 @@
COMMIT_SHA := `git rev-parse HEAD`

dev:
cargo watch -w src/ -x run

docker-build:
docker build --build-arg COMMIT_SHA="{{COMMIT_SHA}}" .

docker-build-quiet:
docker build --build-arg COMMIT_SHA="{{COMMIT_SHA}}" --quiet .

docker-run:
docker run --env OPENAI_API_KEY --publish 8080:8080 "$(just docker-build-quiet)"

deploy:
fly deploy --env COMMIT_SHA="$(git rev-parse HEAD)"
fly deploy --build-arg

logs:
fly logs
26 changes: 26 additions & 0 deletions src/env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use std::env;

use tokio::process::Command;

pub fn print() {
Command::new("env")
.spawn()
.expect("env command failed to start");
}

pub fn commit_sha() -> String {
env::var("COMMIT_SHA").unwrap_or_else(|_| "unknown".to_string())
}

pub fn docker() -> bool {
let var = env::var("DOCKER");
var.is_ok() && var.unwrap() == "true"
}

pub fn http_basic_auth_password() -> Option<String> {
env::var("HTTP_BASIC_AUTH_PASSWORD").ok()
}

pub fn openai_api_key() -> Option<String> {
env::var("OPENAI_API_KEY").ok()
}
11 changes: 6 additions & 5 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use std::fmt::Display;

use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use std::fmt::Display;
use strum_macros::AsRefStr;

pub type Result<T> = core::result::Result<T, Error>;

#[derive(Debug, AsRefStr)]
#[derive(Debug)]
pub enum Error {
SystemTimeError,
EnvironmentError,
Expand All @@ -20,7 +20,8 @@ pub enum Error {

impl IntoResponse for Error {
fn into_response(self) -> Response {
println!("->> {:<12} - {self:?}", "INTO_RES");
println!("\n----------");
println!("Error: {self:?}");

// Create a placeholder Axum reponse.
let mut response = StatusCode::INTERNAL_SERVER_ERROR.into_response();
Expand All @@ -34,7 +35,7 @@ impl IntoResponse for Error {

impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_ref())
write!(f, "{:?}", self)
}
}

Expand Down
217 changes: 15 additions & 202 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,214 +1,27 @@
pub use self::error::{Error, Result};
use std::net::SocketAddr;

use axum::Server;

use axum::{
body::StreamBody,
http::{header, Uri},
response::IntoResponse,
routing::{get, get_service},
Json, Router, Server,
};
use axum_extra::middleware::option_layer;
use eventsource_stream::Eventsource;
use futures::future;
use futures::{StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use std::{
env,
io::{self, Write},
net::SocketAddr,
time::SystemTime,
};
use tokio::signal;
use tower_http::{services::ServeDir, validate_request::ValidateRequestHeaderLayer};
pub use self::error::{Error, Result};

mod env;
mod error;
mod signal;
mod web;

#[tokio::main]
async fn main() {
println!("Starting server...");

let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
Server::bind(&addr)
.serve(app().into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await
.expect("server should serve");
}

fn app() -> Router {
let auth = option_layer(
env::var("HTTP_BASIC_AUTH_PASSWORD")
.ok()
.map(|password| ValidateRequestHeaderLayer::basic("user", &password)),
);

Router::new().route("/health", get(health)).nest_service(
"/",
// Handle GET.
get_service(
// Serve static files from "public".
ServeDir::new("public")
// When static file missing use handler.
.fallback(get(handler)),
)
// Other methods use handler.
.post(handler)
.patch(handler)
.put(handler)
.delete(handler)
// Apply HTTP basic auth.
.layer(auth),
)
}

async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};

let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};

tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}

println!("signal received, starting graceful shutdown");
}

#[derive(Debug, Serialize)]
struct HealthBody {
time: u64,
commit_sha: String,
basic_auth_enabled: bool,
}

#[derive(Debug, Serialize)]
struct ChatCompletionsBody {
model: String,
stream: bool,
messages: Vec<Message>,
}

#[derive(Debug, Serialize)]
struct Message {
role: String,
content: String,
}

#[derive(Debug, Deserialize)]
struct Event {
choices: Vec<Choice>,
}

#[derive(Debug, Deserialize)]
struct Choice {
delta: Delta,
}

#[derive(Debug, Deserialize)]
struct Delta {
content: String,
}

async fn health() -> Result<impl IntoResponse> {
println!("\n----------");
println!("Health");
println!("----------");

env::var("OPENAI_API_KEY").map_err(|_| Error::EnvironmentError)?;

let time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| Error::SystemTimeError)?
.as_secs();
let commit_sha = env::var("COMMIT_SHA").unwrap_or_else(|_| "unknown".to_string());
let basic_auth_enabled = env::var("HTTP_BASIC_AUTH_PASSWORD").is_ok();

Ok(Json(HealthBody {
time,
basic_auth_enabled,
commit_sha,
}))
}

async fn handler(uri: Uri) -> Result<impl IntoResponse> {
println!("\n----------");
println!("Fetching: {uri}");
println!("----------");

let prompt = r#"
Output a valid HTML document for the webpage that could be located at the URL path provided by the user. Include general navigation anchor tags as well as relative anchor tags to other related pages. Include a minimal amount of inline styles to improve the look of the page. Make the text content quite long with a decent amount of interesting content. Do not use any dummy text on the page.
Start the reponse with the following exact characters:
<!doctype html>
<html>"#;

let body = ChatCompletionsBody {
model: "gpt-3.5-turbo".to_string(),
stream: true,
messages: vec![
Message {
role: "system".to_string(),
content: prompt.to_string(),
},
Message {
role: "user".to_string(),
content: uri.to_string(),
},
],
let addr = if env::docker() {
SocketAddr::from(([0, 0, 0, 0], 8080))
} else {
SocketAddr::from(([127, 0, 0, 1], 8080))
};

let stream = reqwest::Client::new()
.post("https://api.openai.com/v1/chat/completions")
.header("content-type", "application/json")
.header(
"authorization",
&format!(
"Bearer {}",
env::var("OPENAI_API_KEY").map_err(|_| Error::EnvironmentError)?
),
)
.body(serde_json::to_string(&body).map_err(|_| Error::SerializationError)?)
.send()
Server::bind(&addr)
.serve(web::app().into_make_service())
.with_graceful_shutdown(signal::shutdown())
.await
.map_err(|_| Error::RequestError)?
.bytes_stream()
.eventsource()
.map(|r| match r {
Ok(e) => {
serde_json::from_str::<Event>(&e.data).map_err(|_| Error::DeserializationError)
}
_ => Err(Error::StreamError),
})
// Discard errors (will most likely be `Error::JsonError`).
.filter(|r| future::ready(r.is_ok()))
.map_ok(|event| {
let content = event
.choices
.into_iter()
.next()
.expect("event should have at least one choice")
.delta
.content;

// Debug log.
print!("{}", content);
let _ = io::stdout().flush();

content
});

Ok((
[(header::CONTENT_TYPE, "text/html")],
StreamBody::new(stream),
))
.expect("server should serve");
}
Loading

0 comments on commit f7b8ebc

Please sign in to comment.